Quantum-Safe Cloud -- Part 5
Quantum-Safe CI/CD: No More Secrets in Pipelines
The Problem
The pipeline from the ATLAS+GOTCHA series is a good start. It builds, tests, scans with Trivy, and deploys with zero-downtime rolling updates.
But it has three security gaps we didn’t address in that series.
Gap 1: Variable groups are not quantum-safe. Azure DevOps variable groups with Key Vault backing are encrypted at rest with Azure-managed keys — RSA by default. Better than plaintext in YAML. Not safe against harvest-now-decrypt-later. And the secrets themselves transit through Azure DevOps agent memory in plaintext.
Gap 2: Images are unsigned. Trivy scans the image for CVEs before pushing. But once the image is in ACR, anyone with write access can push a new image with the same tag. There’s nothing stopping a malicious image from replacing a clean one. The Kubernetes deployment has no way to verify that what it’s pulling is what the pipeline pushed.
Gap 3: Supply chain. The Trivy scan catches vulnerable packages. But it doesn’t verify that the base image hasn’t been tampered with, that the build agent hasn’t been compromised, or that the NuGet packages are the ones you expect. These are supply chain attack vectors. SolarWinds, Log4Shell, XZ Utils — they all exploited this gap.
This article fixes all three: QuantumVault for pipeline secrets, Cosign for image signing, and SBOM generation for supply chain transparency.
The Solution
Three additions to the existing pipeline:
-
QuantumVault secret resolution: pipeline fetches secrets from QuantumVault at runtime via the
qapiCLI. Zero secrets stored in Azure DevOps. -
Cosign image signing: after pushing to ACR, the pipeline signs the image with an ML-DSA key stored in QuantumVault. Kubernetes admission control verifies the signature before running any pod.
-
SBOM generation:
dotnet publishgenerates a CycloneDX SBOM. The SBOM is attached to the container image and pushed to ACR alongside the image. Audit trail for every dependency in production.
Execute
ATLAS for this task
[A] ARCHITECT
Extend the existing Azure DevOps pipeline (users-api).
Remove all secrets from variable groups.
Sign every image pushed to ACR.
Generate and attach SBOM to every image.
Out of scope: Kubernetes admission webhook (Gatekeeper/Ratify) — article 6.
[T] TRACE
Pipeline starts → qapi CLI authenticates to QuantumVault using a service account API key
→ Fetches DB_CONNECTION, JWT_SECRET (now from QuantumVault, not variable group)
→ Build + test (same as before)
→ docker build → Trivy scan → docker push to ACR (same as before)
→ Cosign: fetch ML-DSA signing key from QuantumVault → sign the image in ACR
→ SBOM: generate CycloneDX → attach to image in ACR
→ Deploy: kubectl rolling update (same as before)
[L] LINK
ADO pipeline → QuantumVault: HTTPS, service account API key (stored in ADO variable group
as QUANTUMAPI_KEY — this is the only secret remaining in ADO)
ADO pipeline → ACR: service connection (unchanged)
Cosign → ACR: OCI artifact push
Cosign → QuantumVault: fetch signing key material
[A] ASSEMBLE
Phase 1: Install qapi CLI on the build agent
Phase 2: Replace variable group secret reads with qapi vault get calls
Phase 3: Add Cosign signing stage after image push
Phase 4: Add SBOM generation stage
[S] STRESS-TEST
QuantumVault unreachable during pipeline?
→ qapi returns exit code 1 → pipeline fails fast before build
Image signing fails?
→ Don't deploy an unsigned image — fail the pipeline
SBOM generation fails?
→ Warn, but don't fail — SBOM is audit trail, not a gate
Step 1: Install the qapi CLI
Add an install step at the top of the pipeline:
# At the top of your existing azure-pipelines.yml
variables:
- name: QAPI_VERSION
value: "1.2.0"
stages:
# ─────────────────────────────────────────
- stage: setup
displayName: Setup
jobs:
- job: install_tools
displayName: Install qapi CLI
pool:
vmImage: ubuntu-latest
steps:
- script: |
curl -sfL https://cli.quantumapi.eu/install.sh \
| sh -s -- -v $(QAPI_VERSION) -b /usr/local/bin
qapi version
displayName: Install qapi
env:
QUANTUMAPI_KEY: $(QUANTUMAPI_KEY) # ← only ADO secret remaining
The QUANTUMAPI_KEY is the service account API key for your pipeline. This is the only secret that stays in Azure DevOps variable groups. Everything else is fetched from QuantumVault.
Step 2: Replace variable group secrets with QuantumVault
Before (article 6 pipeline):
variables:
- group: users-api-secrets # DB_CONNECTION, JWT_SECRET here
After (quantum-safe pipeline):
variables:
- name: QUANTUMAPI_KEY # Only this remains — the bootstrap key
value: $(QUANTUMAPI_KEY) # From ADO variable group, not Key Vault
- name: DB_SECRET_ID
value: "7c3a1f9d-4e2b-8a6c-0f5d-3b9e1c7a4f2d" # Just an ID, not a secret
- name: JWT_SECRET_ID
value: "2a8f4c1e-9b3d-7f5a-1e4c-6d0b8e3f2a9c"
In the build stage, fetch secrets at runtime:
- stage: build
displayName: Build & Test
jobs:
- job: build
pool:
vmImage: ubuntu-latest
steps:
- script: |
# Fetch secrets from QuantumVault at runtime
DB_CONNECTION=$(qapi vault get $(DB_SECRET_ID))
JWT_SECRET=$(qapi vault get $(JWT_SECRET_ID))
# Export as pipeline variables (masked in logs automatically)
echo "##vso[task.setvariable variable=DB_CONNECTION;issecret=true]$DB_CONNECTION"
echo "##vso[task.setvariable variable=JWT_SECRET;issecret=true]$JWT_SECRET"
displayName: Fetch secrets from QuantumVault
env:
QUANTUMAPI_KEY: $(QUANTUMAPI_KEY)
- task: UseDotNet@2
inputs:
version: "10.x"
- script: dotnet restore
displayName: Restore
- script: dotnet build --no-restore --configuration Release
displayName: Build
env:
ConnectionStrings__DefaultConnection: $(DB_CONNECTION)
JwtSettings__Secret: $(JWT_SECRET)
- script: |
dotnet test --no-build --configuration Release \
--logger "junit;LogFilePath=$(Agent.TempDirectory)/test-results.xml"
displayName: Test
env:
ConnectionStrings__DefaultConnection: $(DB_CONNECTION)
- task: PublishTestResults@2
condition: always()
inputs:
testResultsFormat: JUnit
testResultsFiles: "$(Agent.TempDirectory)/test-results.xml"
- script: dotnet format --verify-no-changes
displayName: Format check
The secrets are fetched into ephemeral pipeline variables marked as issecret=true — Azure DevOps masks them in all log output. They exist only for the duration of the pipeline job.
Step 3: Generate SBOM
Add SBOM generation to the build stage, after the build succeeds:
- script: |
dotnet tool install --global CycloneDX
dotnet CycloneDX . \
--output $(Build.ArtifactStagingDirectory)/sbom \
--json \
--filename sbom.json
displayName: Generate SBOM
- task: PublishBuildArtifacts@1
inputs:
PathtoPublish: "$(Build.ArtifactStagingDirectory)/sbom"
ArtifactName: sbom
This generates a CycloneDX JSON SBOM listing every NuGet dependency with its version and known CVEs. The SBOM is published as a build artifact and also attached to the container image in the next stage.
Step 4: Sign the image with Cosign
Add a signing stage after the image push:
# ─────────────────────────────────────────
- stage: sign
displayName: Sign & Attest
dependsOn: image
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- job: sign_image
displayName: Cosign + SBOM attestation
pool:
vmImage: ubuntu-latest
steps:
- script: |
# Install Cosign
curl -sfL https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64 \
-o /usr/local/bin/cosign && chmod +x /usr/local/bin/cosign
# Fetch ML-DSA signing key from QuantumVault
qapi vault get $(SIGNING_KEY_SECRET_ID) \
--format pem > /tmp/signing-key.pem
# Sign the image in ACR
cosign sign \
--key /tmp/signing-key.pem \
--registry-username $(acrUsername) \
--registry-password $(acrPassword) \
$(acrName).azurecr.io/users-api:$(IMAGE_TAG)
# Attach SBOM as attestation
cosign attest \
--key /tmp/signing-key.pem \
--type cyclonedx \
--predicate sbom/sbom.json \
$(acrName).azurecr.io/users-api:$(IMAGE_TAG)
# Clean up key from disk
rm -f /tmp/signing-key.pem
displayName: Sign image and attach SBOM
env:
QUANTUMAPI_KEY: $(QUANTUMAPI_KEY)
COSIGN_EXPERIMENTAL: "1"
Step 5: Verify the signature before deploying
In the deploy stage, verify the signature before running kubectl:
- stage: deploy
displayName: Deploy to AKS
dependsOn: sign
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: deploy_aks
pool:
vmImage: ubuntu-latest
environment: production
strategy:
runOnce:
deploy:
steps:
- script: |
# Fetch public key for verification
qapi keys get $(SIGNING_KEY_ID) --public-only > /tmp/signing-pub.pem
# Verify signature before deploying
cosign verify \
--key /tmp/signing-pub.pem \
$(acrName).azurecr.io/users-api:$(IMAGE_TAG) \
|| (echo "Image signature verification failed" && exit 1)
displayName: Verify image signature
env:
QUANTUMAPI_KEY: $(QUANTUMAPI_KEY)
- task: KubernetesManifest@1
displayName: Deploy to AKS
inputs:
action: patch
resourceToPatch: deployment/users-api
namespace: users-api
patch: |
spec:
template:
spec:
containers:
- name: users-api
image: $(acrName).azurecr.io/users-api:$(IMAGE_TAG)
What the full secret flow looks like
Azure DevOps variable group (before)
├── DB_CONNECTION = "Server=..." ← RSA-encrypted in Azure KV
├── JWT_SECRET = "super-secret" ← RSA-encrypted in Azure KV
└── ACR_NAME = "myacr" ← not a secret
Azure DevOps variable group (after)
├── QUANTUMAPI_KEY = "qid_..." ← only this remains, still ADO-managed
├── DB_SECRET_ID = "7c3a1f9d-..." ← just an ID, not a secret
├── JWT_SECRET_ID = "2a8f4c1e-..." ← just an ID, not a secret
├── SIGNING_KEY_SECRET_ID = "..." ← ID of the PEM in QuantumVault
├── SIGNING_KEY_ID = "..." ← ID of the key pair (for public key fetch)
└── ACR_NAME = "myacr" ← not a secret
QuantumVault (source of truth)
├── users-api/db-connection ← ML-KEM encrypted at rest
├── users-api/jwt-secret ← ML-KEM encrypted at rest
└── users-api/cosign-signing-key ← ML-KEM encrypted at rest
What we adjusted
1. Key cleanup. The AI left the signing key file on disk across steps. We added rm -f /tmp/signing-key.pem immediately after Cosign finishes. On a shared agent pool, this matters.
2. SBOM as warning, not gate. The AI put SBOM generation before Trivy and made it a hard gate. We moved it after the build and made it advisory — a warning in the logs if it fails, not a pipeline failure. The reason: SBOM generation is useful but occasionally fails on complex transitive dependencies. Don’t let it block your deployments.
3. issecret=true on fetched variables. The AI used echo "##vso[task.setvariable variable=DB_CONNECTION]$DB_CONNECTION" — without issecret=true. This would have printed the connection string in the logs. Always mark fetched secrets as secret.
Template
=== QUANTUM-SAFE CI/CD CHECKLIST ===
SECRETS
[ ] Identify all secrets currently in variable groups
[ ] Migrate each secret to QuantumVault (Article 2 process)
[ ] Replace variable values with QuantumVault IDs
[ ] Keep only QUANTUMAPI_KEY in ADO variable group
[ ] Verify: git grep -r "password\|secret\|connectionstring" pipeline YAML → 0
IMAGE SIGNING
[ ] Generate ML-DSA signing key pair in QuantumVault
[ ] Add Cosign signing stage (after image push, before deploy)
[ ] Add Cosign verification step (before kubectl apply)
[ ] Store signing key private PEM in QuantumVault (not on disk longer than needed)
SBOM
[ ] Install CycloneDX tool in build stage
[ ] Generate SBOM after successful build
[ ] Attach SBOM as OCI attestation to image in ACR
[ ] Publish SBOM as build artifact (audit trail)
SUPPLY CHAIN
[ ] All pipeline tools pinned to specific versions (qapi, cosign, trivy)
[ ] Base images pinned by digest (FROM mcr.microsoft.com/dotnet/aspnet@sha256:...)
[ ] NuGet packages pinned in packages.lock.json (dotnet restore --locked-mode)
VERIFY
[ ] Pipeline succeeds end-to-end
[ ] Cosign verify passes for the deployed image
[ ] No secrets visible in pipeline logs
[ ] SBOM attached to image in ACR (cosign download attestation)
Challenge
You now have a quantum-safe application (secrets, encryption, identity) and a quantum-safe pipeline (QuantumVault secrets, signed images, SBOM).
But inside the AKS cluster, services communicate without verifying each other. A compromised pod can call any other service. Network policies limit which pods can talk, but they don’t verify identity.
Article 6 adds the last layer: mTLS between services with ML-DSA certificates. Every service proves its identity on every connection. Zero trust at the service-to-service layer.
If this series helps you, consider buying me a coffee.
Loading comments...