Image Verification
This guide explains how to verify the integrity and provenance of Surface Security container images before deploying them.
Overview
Surface Security images include three layers of supply chain security:
| Layer | Method | Purpose |
|---|---|---|
| Cosign Keyless | Sigstore OIDC | Automated CI signature on every build |
| SBOM | CycloneDX (Syft) | Software bill of materials for dependency auditing |
| SLSA Provenance | BuildKit | Build provenance attestation |
| Portal Threshold | 2-of-3 YubiKey Ed25519 | High-trust release signing ceremony |
Prerequisites
# Install cosign
# https://docs.sigstore.dev/cosign/system_config/installation/
brew install cosign # macOS
# or
go install github.com/sigstore/cosign/v2/cmd/cosign@latest
Images
All images are published to GitHub Container Registry:
| Image | Description |
|---|---|
ghcr.io/<owner>/surfacesec-api | Backend API server |
ghcr.io/<owner>/surfacesec-ingestion | Event ingestion worker |
ghcr.io/<owner>/surfacesec-preflight | Database migration preflight check |
ghcr.io/<owner>/surfacesec-frontend | Next.js admin dashboard |
Verify Cosign Keyless Signature
Every image built by CI is signed using Sigstore keyless signing (OIDC identity from GitHub Actions).
# Verify a specific image version
cosign verify \
--certificate-identity-regexp=".*" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
ghcr.io/<owner>/surfacesec-api:1.2.3
Successful output includes the Rekor transparency log entry and the signing certificate chain.
Download and Inspect SBOM
Each image has a CycloneDX SBOM attached as an OCI artifact.
# Download SBOM
cosign download sbom ghcr.io/<owner>/surfacesec-api:1.2.3 > sbom-api.cdx.json
# Inspect with jq
jq '.components | length' sbom-api.cdx.json
jq '.components[] | .name + "@" + .version' sbom-api.cdx.json
# Scan for vulnerabilities (requires grype)
grype sbom:sbom-api.cdx.json
Verify SLSA Provenance
Images include SLSA provenance attestations generated by BuildKit.
# Verify provenance
cosign verify-attestation \
--type slsaprovenance \
--certificate-identity-regexp=".*" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
ghcr.io/<owner>/surfacesec-api:1.2.3
Verify Portal Threshold Signatures
For official releases, images are additionally signed through a 2-of-3 YubiKey threshold ceremony via the Surface Portal.
Connected Verification
If your deployment can reach the Portal, the backend automatically fetches verification keys and verifies update bundle signatures during the update process. No manual action is needed.
Air-Gapped Verification
For air-gapped deployments, verification keys are embedded in the backend binary at build time.
-
Verify manually with surface-sign CLI:
# Download the signing manifest for your release version# (available as a GitHub Actions artifact on the release-sign workflow run)# Verify the manifest signaturessurface-sign verify \--manifest signing-manifest-1.2.3.json \--keys verification-keys.json \--threshold 2 -
Verify image digests match:
# Compare the digest in the signing manifest with the actual imagedocker inspect --format='{{index .RepoDigests 0}}' surfacesec-api:1.2.3
Air-Gapped Image Transfer
For environments without internet access:
# On a connected machine: pull, verify, and save
VERSION=1.2.3
IMAGES="api ingestion preflight frontend"
for img in $IMAGES; do
# Verify before saving
cosign verify \
--certificate-identity-regexp=".*" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
"ghcr.io/<owner>/surfacesec-${img}:${VERSION}"
# Download SBOM for compliance records
cosign download sbom "ghcr.io/<owner>/surfacesec-${img}:${VERSION}" \
> "sbom-${img}.cdx.json"
done
# Save all images to a tarball
docker save \
ghcr.io/<owner>/surfacesec-api:${VERSION} \
ghcr.io/<owner>/surfacesec-ingestion:${VERSION} \
ghcr.io/<owner>/surfacesec-preflight:${VERSION} \
ghcr.io/<owner>/surfacesec-frontend:${VERSION} \
| gzip > surfacesec-images-${VERSION}.tar.gz
# Generate digest file for verification after transfer
sha256sum surfacesec-images-${VERSION}.tar.gz > surfacesec-images-${VERSION}.sha256
# Transfer tarball + sha256 + SBOMs to air-gapped environment
# On the air-gapped machine: verify and load
sha256sum -c surfacesec-images-${VERSION}.sha256
docker load < surfacesec-images-${VERSION}.tar.gz
# Tag and push to local registry
for img in api ingestion preflight frontend; do
docker tag "ghcr.io/<owner>/surfacesec-${img}:${VERSION}" \
"registry.internal/surfacesec/${img}:${VERSION}"
docker push "registry.internal/surfacesec/${img}:${VERSION}"
done
Verification Key Lifecycle
| Event | Action |
|---|---|
| First boot (air-gapped) | Embedded keys are loaded from the binary |
| First Portal contact | Keys fetched from GET /api/v1/deployment/verification-keys |
| Key rotation | New keys must be signed by 2-of-3 existing keyholders (version monotonicity enforced) |
| Manual override | Place a verification-keys.json file in the data directory |