Integrating ATProto Signatures with OCI Tools#
This guide shows how to work with ATProto signatures using standard OCI/ORAS tools and integrate signature verification into your workflows.
Quick Reference: Tool Compatibility#
| Tool | Discover Signatures | Fetch Signatures | Verify Signatures |
|---|---|---|---|
oras discover |
✅ Yes | - | - |
oras pull |
- | ✅ Yes | ❌ No (custom tool needed) |
oras manifest fetch |
- | ✅ Yes | - |
cosign tree |
✅ Yes (as artifacts) | - | - |
cosign verify |
- | - | ❌ No (different format) |
crane manifest |
- | ✅ Yes | - |
skopeo inspect |
✅ Yes (in referrers) | - | - |
docker |
❌ No (not visible) | - | - |
atcr-verify |
✅ Yes | ✅ Yes | ✅ Yes |
Key Takeaway: Standard OCI tools can discover and fetch ATProto signatures, but verification requires custom tooling because ATProto uses a different trust model than Cosign/Notary.
Understanding What Tools See#
ORAS CLI: Full Support for Discovery#
ORAS understands referrers and can discover ATProto signature artifacts:
# Discover all artifacts attached to an image
$ oras discover atcr.io/alice/myapp:latest
Discovered 2 artifacts referencing alice/myapp@sha256:abc123456789...:
Digest: sha256:abc123456789...
Artifact Type: application/spdx+json
Digest: sha256:sbom123...
Size: 45678
Artifact Type: application/vnd.atproto.signature.v1+json
Digest: sha256:sig789...
Size: 512
What ORAS shows:
- ✅ Artifact type (identifies it as an ATProto signature)
- ✅ Digest (can fetch the artifact)
- ✅ Size
To filter for signatures only:
$ oras discover atcr.io/alice/myapp:latest \
--artifact-type application/vnd.atproto.signature.v1+json
Discovered 1 artifact referencing alice/myapp@sha256:abc123...:
Artifact Type: application/vnd.atproto.signature.v1+json
Digest: sha256:sig789...
Fetching Signature Metadata with ORAS#
Pull the signature artifact to examine it:
# Pull signature artifact to current directory
$ oras pull atcr.io/alice/myapp@sha256:sig789...
Downloaded atproto-signature.json
Pulled atcr.io/alice/myapp@sha256:sig789...
Digest: sha256:sig789...
# Examine the signature metadata
$ cat atproto-signature.json | jq .
{
"$type": "io.atcr.atproto.signature",
"version": "1.0",
"subject": {
"digest": "sha256:abc123...",
"mediaType": "application/vnd.oci.image.manifest.v1+json"
},
"atproto": {
"did": "did:plc:alice123",
"handle": "alice.bsky.social",
"pdsEndpoint": "https://bsky.social",
"recordUri": "at://did:plc:alice123/io.atcr.manifest/abc123",
"commitCid": "bafyreih8...",
"signedAt": "2025-10-31T12:34:56.789Z"
},
"signature": {
"algorithm": "ECDSA-K256-SHA256",
"keyId": "did:plc:alice123#atproto",
"publicKeyMultibase": "zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDdo1Ko4Z"
}
}
Cosign: Discovers but Cannot Verify#
Cosign can see ATProto signatures as artifacts but can't verify them:
# Cosign tree shows attached artifacts
$ cosign tree atcr.io/alice/myapp:latest
📦 Supply Chain Security Related artifacts for an image: atcr.io/alice/myapp:latest
└── 💾 Attestations for an image tag: atcr.io/alice/myapp:sha256-abc123.att
├── 🍒 sha256:sbom123... (application/spdx+json)
└── 🍒 sha256:sig789... (application/vnd.atproto.signature.v1+json)
# Cosign verify doesn't work (expected)
$ cosign verify atcr.io/alice/myapp:latest
Error: no matching signatures:
main.go:62: error during command execution: no matching signatures:
Why cosign verify fails:
- Cosign expects signatures in its own format (
dev.cosignproject.cosign/signatureannotation) - ATProto signatures use a different format and trust model
- This is intentional - we're not trying to be Cosign-compatible
Crane: Fetch Manifests#
Crane can fetch the signature manifest:
# Get signature artifact manifest
$ crane manifest atcr.io/alice/myapp@sha256:sig789... | jq .
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"artifactType": "application/vnd.atproto.signature.v1+json",
"config": {
"mediaType": "application/vnd.oci.empty.v1+json",
"digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"size": 2
},
"subject": {
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:abc123...",
"size": 1234
},
"layers": [{
"mediaType": "application/vnd.atproto.signature.v1+json",
"digest": "sha256:meta456...",
"size": 512
}],
"annotations": {
"io.atcr.atproto.did": "did:plc:alice123",
"io.atcr.atproto.pds": "https://bsky.social",
"io.atcr.atproto.recordUri": "at://did:plc:alice123/io.atcr.manifest/abc123"
}
}
Skopeo: Inspect Images#
Skopeo shows referrers in image inspection:
$ skopeo inspect --raw docker://atcr.io/alice/myapp:latest | jq .
# Standard manifest (no signature info visible in manifest itself)
# To see referrers (if registry supports Referrers API):
$ curl -H "Accept: application/vnd.oci.image.index.v1+json" \
"https://atcr.io/v2/alice/myapp/referrers/sha256:abc123"
Manual Verification with Shell Scripts#
Until atcr-verify is built, you can verify signatures manually:
Simple Verification Script#
#!/bin/bash
# verify-atproto-signature.sh
# Usage: ./verify-atproto-signature.sh atcr.io/alice/myapp:latest
set -e
IMAGE="$1"
echo "[1/6] Resolving image digest..."
DIGEST=$(crane digest "$IMAGE")
echo " → $DIGEST"
echo "[2/6] Discovering ATProto signature..."
REGISTRY=$(echo "$IMAGE" | cut -d/ -f1)
REPO=$(echo "$IMAGE" | cut -d/ -f2-)
REPO_PATH=$(echo "$REPO" | cut -d: -f1)
SIG_ARTIFACTS=$(curl -s -H "Accept: application/vnd.oci.image.index.v1+json" \
"https://${REGISTRY}/v2/${REPO_PATH}/referrers/${DIGEST}?artifactType=application/vnd.atproto.signature.v1+json")
SIG_DIGEST=$(echo "$SIG_ARTIFACTS" | jq -r '.manifests[0].digest')
if [ "$SIG_DIGEST" = "null" ]; then
echo " ✗ No ATProto signature found"
exit 1
fi
echo " → Found signature: $SIG_DIGEST"
echo "[3/6] Fetching signature metadata..."
oras pull "${REGISTRY}/${REPO_PATH}@${SIG_DIGEST}" -o /tmp/sig --quiet
DID=$(jq -r '.atproto.did' /tmp/sig/atproto-signature.json)
PDS=$(jq -r '.atproto.pdsEndpoint' /tmp/sig/atproto-signature.json)
RECORD_URI=$(jq -r '.atproto.recordUri' /tmp/sig/atproto-signature.json)
echo " → DID: $DID"
echo " → PDS: $PDS"
echo " → Record: $RECORD_URI"
echo "[4/6] Resolving DID to public key..."
DID_DOC=$(curl -s "https://plc.directory/$DID")
PUB_KEY_MB=$(echo "$DID_DOC" | jq -r '.verificationMethod[0].publicKeyMultibase')
echo " → Public key: $PUB_KEY_MB"
echo "[5/6] Querying PDS for signed commit..."
# Extract collection and rkey from record URI
COLLECTION=$(echo "$RECORD_URI" | sed 's|at://[^/]*/\([^/]*\)/.*|\1|')
RKEY=$(echo "$RECORD_URI" | sed 's|at://.*/||')
RECORD=$(curl -s "${PDS}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=${COLLECTION}&rkey=${RKEY}")
RECORD_CID=$(echo "$RECORD" | jq -r '.cid')
echo " → Record CID: $RECORD_CID"
echo "[6/6] Verifying signature..."
echo " ⚠ Note: Full cryptographic verification requires ATProto crypto library"
echo " ⚠ This script verifies record existence and DID resolution only"
echo ""
echo " ✓ Record exists in PDS"
echo " ✓ DID resolved successfully"
echo " ✓ Public key retrieved"
echo ""
echo "To fully verify the cryptographic signature, use: atcr-verify $IMAGE"
Full Verification (Requires Go + indigo)#
// verify.go - Full cryptographic verification
package main
import (
"encoding/json"
"fmt"
"net/http"
"github.com/bluesky-social/indigo/atproto/crypto"
"github.com/multiformats/go-multibase"
)
func verifyATProtoSignature(did, pds, recordURI string) error {
// 1. Resolve DID to public key
didDoc, err := fetchDIDDocument(did)
if err != nil {
return fmt.Errorf("failed to resolve DID: %w", err)
}
pubKeyMB := didDoc.VerificationMethod[0].PublicKeyMultibase
// 2. Decode multibase public key
_, pubKeyBytes, err := multibase.Decode(pubKeyMB)
if err != nil {
return fmt.Errorf("failed to decode public key: %w", err)
}
// Remove multicodec prefix (first 2 bytes for K-256)
pubKeyBytes = pubKeyBytes[2:]
// 3. Parse as K-256 public key
pubKey, err := crypto.ParsePublicKeyK256(pubKeyBytes)
if err != nil {
return fmt.Errorf("failed to parse public key: %w", err)
}
// 4. Fetch repository commit from PDS
commit, err := fetchRepoCommit(pds, did)
if err != nil {
return fmt.Errorf("failed to fetch commit: %w", err)
}
// 5. Verify signature
bytesToVerify := commit.Unsigned().BytesForSigning()
err = pubKey.Verify(bytesToVerify, commit.Sig)
if err != nil {
return fmt.Errorf("signature verification failed: %w", err)
}
fmt.Println("✓ Signature verified successfully!")
return nil
}
func fetchDIDDocument(did string) (*DIDDocument, error) {
resp, err := http.Get(fmt.Sprintf("https://plc.directory/%s", did))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var didDoc DIDDocument
err = json.NewDecoder(resp.Body).Decode(&didDoc)
return &didDoc, err
}
// ... additional helper functions
Kubernetes Integration#
Option 1: Admission Webhook (Recommended)#
Create a validating webhook that verifies ATProto signatures:
# atcr-verify-webhook.yaml
apiVersion: v1
kind: Service
metadata:
name: atcr-verify
namespace: kube-system
spec:
selector:
app: atcr-verify
ports:
- port: 443
targetPort: 8443
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: atcr-verify
namespace: kube-system
spec:
replicas: 2
selector:
matchLabels:
app: atcr-verify
template:
metadata:
labels:
app: atcr-verify
spec:
containers:
- name: webhook
image: atcr.io/atcr/verify-webhook:latest
ports:
- containerPort: 8443
env:
- name: REQUIRE_SIGNATURE
value: "true"
- name: TRUSTED_DIDS
value: "did:plc:alice123,did:plc:bob456"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: atcr-verify
webhooks:
- name: verify.atcr.io
clientConfig:
service:
name: atcr-verify
namespace: kube-system
path: /validate
caBundle: <base64-encoded-ca-cert>
rules:
- operations: ["CREATE", "UPDATE"]
apiGroups: [""]
apiVersions: ["v1"]
resources: ["pods"]
admissionReviewVersions: ["v1", "v1beta1"]
sideEffects: None
failurePolicy: Fail # Reject pods if verification fails
namespaceSelector:
matchExpressions:
- key: atcr-verify
operator: In
values: ["enabled"]
Webhook Server Logic (pseudocode):
func (h *WebhookHandler) ValidatePod(req *admissionv1.AdmissionRequest) *admissionv1.AdmissionResponse {
pod := &corev1.Pod{}
json.Unmarshal(req.Object.Raw, pod)
for _, container := range pod.Spec.Containers {
if !strings.HasPrefix(container.Image, "atcr.io/") {
continue // Only verify ATCR images
}
// Verify ATProto signature
err := verifyImageSignature(container.Image)
if err != nil {
return &admissionv1.AdmissionResponse{
Allowed: false,
Result: &metav1.Status{
Message: fmt.Sprintf("Image %s failed ATProto verification: %v",
container.Image, err),
},
}
}
}
return &admissionv1.AdmissionResponse{Allowed: true}
}
Enable verification for specific namespaces:
# Label namespace to enable verification
kubectl label namespace production atcr-verify=enabled
# Pods in this namespace must have valid ATProto signatures
kubectl apply -f pod.yaml -n production
Option 2: Kyverno Policy#
Use Kyverno for policy-based validation:
# kyverno-atcr-policy.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-atcr-signatures
spec:
validationFailureAction: enforce
background: false
rules:
- name: atcr-images-must-be-signed
match:
any:
- resources:
kinds:
- Pod
validate:
message: "ATCR images must have valid ATProto signatures"
foreach:
- list: "request.object.spec.containers"
deny:
conditions:
all:
- key: "{{ element.image }}"
operator: In
value: "atcr.io/*"
- key: "{{ atcrVerifySignature(element.image) }}"
operator: NotEquals
value: true
Note: Requires custom Kyverno extension for atcrVerifySignature() function or external service integration.
Option 3: Ratify Verifier Plugin (Recommended) ⭐#
Ratify is a verification engine that integrates with OPA Gatekeeper. Build a custom verifier plugin for ATProto signatures:
Ratify Plugin Architecture:
// pkg/verifier/atproto/verifier.go
package atproto
import (
"context"
"encoding/json"
"github.com/ratify-project/ratify/pkg/common"
"github.com/ratify-project/ratify/pkg/ocispecs"
"github.com/ratify-project/ratify/pkg/referrerstore"
"github.com/ratify-project/ratify/pkg/verifier"
)
type ATProtoVerifier struct {
name string
config ATProtoConfig
resolver *Resolver
}
type ATProtoConfig struct {
TrustedDIDs []string `json:"trustedDIDs"`
}
func (v *ATProtoVerifier) Name() string {
return v.name
}
func (v *ATProtoVerifier) Type() string {
return "atproto"
}
func (v *ATProtoVerifier) CanVerify(artifactType string) bool {
return artifactType == "application/vnd.atproto.signature.v1+json"
}
func (v *ATProtoVerifier) VerifyReference(
ctx context.Context,
subjectRef common.Reference,
referenceDesc ocispecs.ReferenceDescriptor,
store referrerstore.ReferrerStore,
) (verifier.VerifierResult, error) {
// 1. Fetch signature blob from store
sigBlob, err := store.GetBlobContent(ctx, subjectRef, referenceDesc.Digest)
if err != nil {
return verifier.VerifierResult{IsSuccess: false}, err
}
// 2. Parse ATProto signature metadata
var sigData ATProtoSignature
if err := json.Unmarshal(sigBlob, &sigData); err != nil {
return verifier.VerifierResult{IsSuccess: false}, err
}
// 3. Resolve DID to public key
pubKey, err := v.resolver.ResolveDIDToPublicKey(ctx, sigData.ATProto.DID)
if err != nil {
return verifier.VerifierResult{IsSuccess: false}, err
}
// 4. Fetch repository commit from PDS
commit, err := v.resolver.FetchCommit(ctx, sigData.ATProto.PDSEndpoint,
sigData.ATProto.DID, sigData.ATProto.CommitCID)
if err != nil {
return verifier.VerifierResult{IsSuccess: false}, err
}
// 5. Verify K-256 signature
valid := verifyK256Signature(pubKey, commit.Unsigned(), commit.Sig)
if !valid {
return verifier.VerifierResult{IsSuccess: false},
fmt.Errorf("signature verification failed")
}
// 6. Check trust policy
if !v.isTrusted(sigData.ATProto.DID) {
return verifier.VerifierResult{IsSuccess: false},
fmt.Errorf("DID %s not in trusted list", sigData.ATProto.DID)
}
return verifier.VerifierResult{
IsSuccess: true,
Name: v.name,
Type: v.Type(),
Message: fmt.Sprintf("Verified for DID %s", sigData.ATProto.DID),
Extensions: map[string]interface{}{
"did": sigData.ATProto.DID,
"handle": sigData.ATProto.Handle,
"signedAt": sigData.ATProto.SignedAt,
"commitCid": sigData.ATProto.CommitCID,
},
}, nil
}
func (v *ATProtoVerifier) isTrusted(did string) bool {
for _, trustedDID := range v.config.TrustedDIDs {
if did == trustedDID {
return true
}
}
return false
}
Deploy Ratify with ATProto Plugin:
- Build plugin:
CGO_ENABLED=0 go build -o atproto-verifier ./cmd/ratify-atproto-plugin
- Create custom Ratify image:
FROM ghcr.io/ratify-project/ratify:latest
COPY atproto-verifier /.ratify/plugins/atproto-verifier
- Deploy Ratify:
apiVersion: apps/v1
kind: Deployment
metadata:
name: ratify
namespace: gatekeeper-system
spec:
replicas: 1
selector:
matchLabels:
app: ratify
template:
metadata:
labels:
app: ratify
spec:
containers:
- name: ratify
image: atcr.io/atcr/ratify-with-atproto:latest
args:
- serve
- --config=/config/ratify-config.yaml
volumeMounts:
- name: config
mountPath: /config
volumes:
- name: config
configMap:
name: ratify-config
- Configure Verifier:
apiVersion: config.ratify.deislabs.io/v1beta1
kind: Verifier
metadata:
name: atproto-verifier
spec:
name: atproto
artifactType: application/vnd.atproto.signature.v1+json
address: /.ratify/plugins/atproto-verifier
parameters:
trustedDIDs:
- did:plc:alice123
- did:plc:bob456
- Use with Gatekeeper:
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: RatifyVerification
metadata:
name: atcr-signatures-required
spec:
enforcementAction: deny
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
Benefits:
- ✅ Standard plugin interface
- ✅ Works with existing Ratify deployments
- ✅ Can combine with other verifiers (Notation, Cosign)
- ✅ Policy-based enforcement via Gatekeeper
See Also: Integration Strategy - Ratify Plugin
Option 4: OPA Gatekeeper External Data Provider ⭐#
Use Gatekeeper's External Data Provider feature to verify ATProto signatures:
Provider Service:
// cmd/gatekeeper-provider/main.go
package main
import (
"context"
"encoding/json"
"net/http"
"github.com/atcr-io/atcr/pkg/verify"
)
type ProviderRequest struct {
Keys []string `json:"keys"`
Values []string `json:"values"`
}
type ProviderResponse struct {
SystemError string `json:"system_error,omitempty"`
Responses []map[string]interface{} `json:"responses"`
}
func handleProvide(w http.ResponseWriter, r *http.Request) {
var req ProviderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Verify each image
responses := make([]map[string]interface{}, 0, len(req.Values))
for _, image := range req.Values {
result, err := verifier.Verify(context.Background(), image)
response := map[string]interface{}{
"image": image,
"verified": false,
}
if err == nil && result.Verified {
response["verified"] = true
response["did"] = result.Signature.DID
response["signedAt"] = result.Signature.SignedAt
}
responses = append(responses, response)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ProviderResponse{
Responses: responses,
})
}
func main() {
http.HandleFunc("/provide", handleProvide)
http.ListenAndServe(":8080", nil)
}
Deploy Provider:
apiVersion: apps/v1
kind: Deployment
metadata:
name: atcr-provider
namespace: gatekeeper-system
spec:
replicas: 2
selector:
matchLabels:
app: atcr-provider
template:
metadata:
labels:
app: atcr-provider
spec:
containers:
- name: provider
image: atcr.io/atcr/gatekeeper-provider:latest
ports:
- containerPort: 8080
env:
- name: ATCR_POLICY_FILE
value: /config/trust-policy.yaml
volumeMounts:
- name: config
mountPath: /config
volumes:
- name: config
configMap:
name: atcr-trust-policy
---
apiVersion: v1
kind: Service
metadata:
name: atcr-provider
namespace: gatekeeper-system
spec:
selector:
app: atcr-provider
ports:
- port: 80
targetPort: 8080
Configure Gatekeeper:
apiVersion: config.gatekeeper.sh/v1alpha1
kind: Config
metadata:
name: config
namespace: gatekeeper-system
spec:
sync:
syncOnly:
- group: ""
version: "v1"
kind: "Pod"
validation:
traces:
- user: "gatekeeper"
dump: "All"
---
apiVersion: externaldata.gatekeeper.sh/v1alpha1
kind: Provider
metadata:
name: atcr-verifier
spec:
url: http://atcr-provider.gatekeeper-system/provide
timeout: 10
Policy (Rego):
package verify
import future.keywords.contains
import future.keywords.if
import future.keywords.in
# External data call
provider := "atcr-verifier"
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
startswith(container.image, "atcr.io/")
# Call external provider
response := external_data({
"provider": provider,
"keys": ["image"],
"values": [container.image]
})
# Check verification result
not response[_].verified == true
msg := sprintf("Image %v has no valid ATProto signature", [container.image])
}
Benefits:
- ✅ Uses standard Gatekeeper external data API
- ✅ Flexible Rego policies
- ✅ Can add caching, rate limiting
- ✅ Easy to deploy and update
See Also: Integration Strategy - Gatekeeper Provider
Option 5: OPA Gatekeeper#
Use OPA for policy enforcement:
# gatekeeper-constraint-template.yaml
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: atcrverify
spec:
crd:
spec:
names:
kind: ATCRVerify
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package atcrverify
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
startswith(container.image, "atcr.io/")
not verified(container.image)
msg := sprintf("Image %v has no valid ATProto signature", [container.image])
}
verified(image) {
# Call external verification service
response := http.send({
"method": "GET",
"url": sprintf("http://atcr-verify.kube-system.svc/verify?image=%v", [image]),
})
response.status_code == 200
}
---
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: ATCRVerify
metadata:
name: atcr-signatures-required
spec:
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
CI/CD Integration#
GitHub Actions#
# .github/workflows/verify-and-deploy.yml
name: Verify and Deploy
on:
push:
branches: [main]
jobs:
verify-image:
runs-on: ubuntu-latest
steps:
- name: Install ORAS
run: |
curl -LO https://github.com/oras-project/oras/releases/download/v1.0.0/oras_1.0.0_linux_amd64.tar.gz
tar -xzf oras_1.0.0_linux_amd64.tar.gz
sudo mv oras /usr/local/bin/
- name: Install crane
run: |
curl -sL "https://github.com/google/go-containerregistry/releases/download/v0.15.2/go-containerregistry_Linux_x86_64.tar.gz" > crane.tar.gz
tar -xzf crane.tar.gz
sudo mv crane /usr/local/bin/
- name: Verify image signature
run: |
IMAGE="atcr.io/alice/myapp:${{ github.sha }}"
# Get image digest
DIGEST=$(crane digest "$IMAGE")
# Check for ATProto signature
REFERRERS=$(curl -s "https://atcr.io/v2/alice/myapp/referrers/${DIGEST}?artifactType=application/vnd.atproto.signature.v1+json")
SIG_COUNT=$(echo "$REFERRERS" | jq '.manifests | length')
if [ "$SIG_COUNT" -eq 0 ]; then
echo "❌ No ATProto signature found"
exit 1
fi
echo "✓ Found $SIG_COUNT signature(s)"
# TODO: Full verification when atcr-verify is available
# atcr-verify "$IMAGE" --policy policy.yaml
- name: Deploy to Kubernetes
if: success()
run: |
kubectl set image deployment/myapp myapp=atcr.io/alice/myapp:${{ github.sha }}
GitLab CI#
# .gitlab-ci.yml
verify_image:
stage: verify
image: alpine:latest
before_script:
- apk add --no-cache curl jq
script:
- |
IMAGE="atcr.io/alice/myapp:${CI_COMMIT_SHA}"
# Install crane
wget https://github.com/google/go-containerregistry/releases/download/v0.15.2/go-containerregistry_Linux_x86_64.tar.gz
tar -xzf go-containerregistry_Linux_x86_64.tar.gz crane
# Get digest
DIGEST=$(./crane digest "$IMAGE")
# Check signature
REFERRERS=$(curl -s "https://atcr.io/v2/alice/myapp/referrers/${DIGEST}?artifactType=application/vnd.atproto.signature.v1+json")
if [ $(echo "$REFERRERS" | jq '.manifests | length') -eq 0 ]; then
echo "❌ No signature found"
exit 1
fi
echo "✓ Signature verified"
deploy:
stage: deploy
dependencies:
- verify_image
script:
- kubectl set image deployment/myapp myapp=atcr.io/alice/myapp:${CI_COMMIT_SHA}
Integration with Containerd#
Containerd can be extended with verification plugins:
// containerd-atcr-verifier plugin
package main
import (
"context"
"fmt"
"github.com/containerd/containerd"
"github.com/containerd/containerd/remotes"
)
type ATCRVerifier struct {
// Configuration
}
func (v *ATCRVerifier) Verify(ctx context.Context, ref string) error {
// 1. Query referrers API for signatures
sigs, err := v.discoverSignatures(ctx, ref)
if err != nil {
return err
}
if len(sigs) == 0 {
return fmt.Errorf("no ATProto signature found for %s", ref)
}
// 2. Fetch and verify signature
for _, sig := range sigs {
err := v.verifySignature(ctx, sig)
if err == nil {
return nil // At least one valid signature
}
}
return fmt.Errorf("all signatures failed verification")
}
// Use as containerd resolver wrapper
func NewVerifyingResolver(base remotes.Resolver, verifier *ATCRVerifier) remotes.Resolver {
return &verifyingResolver{
Resolver: base,
verifier: verifier,
}
}
Containerd config (/etc/containerd/config.toml):
[plugins."io.containerd.grpc.v1.cri".registry]
[plugins."io.containerd.grpc.v1.cri".registry.configs]
[plugins."io.containerd.grpc.v1.cri".registry.configs."atcr.io"]
[plugins."io.containerd.grpc.v1.cri".registry.configs."atcr.io".auth]
username = "alice"
password = "..."
# Custom verifier hook (requires plugin)
verify_signatures = true
signature_type = "atproto"
Trust Policies#
Define what signatures you trust:
# trust-policy.yaml
version: 1.0
policies:
# Production images must be signed
- name: production-images
scope: "atcr.io/*/prod-*"
require:
- signature: true
trustedDIDs:
- did:plc:alice123
- did:plc:bob456
minSignatures: 1
action: enforce # reject if policy fails
# Development images don't require signatures
- name: dev-images
scope: "atcr.io/*/dev-*"
require:
- signature: false
action: audit # log but don't reject
# Staging requires at least 1 signature from any trusted DID
- name: staging-images
scope: "atcr.io/*/staging-*"
require:
- signature: true
trustedDIDs:
- did:plc:alice123
- did:plc:bob456
- did:plc:charlie789
action: enforce
# DID trust configuration
trustedDIDs:
did:plc:alice123:
name: "Alice (DevOps Lead)"
validFrom: "2024-01-01T00:00:00Z"
expiresAt: null
did:plc:bob456:
name: "Bob (Security Team)"
validFrom: "2024-06-01T00:00:00Z"
expiresAt: "2025-12-31T23:59:59Z"
Troubleshooting#
No Signature Found#
$ oras discover atcr.io/alice/myapp:latest --artifact-type application/vnd.atproto.signature.v1+json
Discovered 0 artifacts
Possible causes:
- Image was pushed before signature creation was implemented
- Signature artifact creation failed
- Registry doesn't support Referrers API
Solutions:
- Re-push the image to generate signature
- Check AppView logs for signature creation errors
- Verify Referrers API endpoint:
GET /v2/{repo}/referrers/{digest}
Signature Verification Fails#
Check DID resolution:
curl -s "https://plc.directory/did:plc:alice123" | jq .
# Should return DID document with verificationMethod
Check PDS connectivity:
curl -s "https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=did:plc:alice123" | jq .
# Should return repository metadata
Check record exists:
curl -s "https://bsky.social/xrpc/com.atproto.repo.getRecord?\
repo=did:plc:alice123&\
collection=io.atcr.manifest&\
rkey=abc123" | jq .
# Should return manifest record
Registry Returns 404 for Referrers#
Some registries don't support OCI Referrers API yet. Fallback to tag-based discovery:
# Look for signature tags (if implemented)
crane ls atcr.io/alice/myapp | grep sig
Best Practices#
1. Always Verify in Production#
Enable signature verification for production namespaces:
kubectl label namespace production atcr-verify=enabled
2. Use Trust Policies#
Don't blindly trust all signatures - define which DIDs you trust:
trustedDIDs:
- did:plc:your-org-team
- did:plc:your-ci-system
3. Monitor Signature Coverage#
Track which images have signatures:
# Check all images in a namespace
kubectl get pods -n production -o json | \
jq -r '.items[].spec.containers[].image' | \
while read image; do
echo -n "$image: "
oras discover "$image" --artifact-type application/vnd.atproto.signature.v1+json | \
grep -q "Discovered 0" && echo "❌ No signature" || echo "✓ Signed"
done
4. Automate Verification in CI/CD#
Never deploy unsigned images to production:
# GitHub Actions
- name: Verify signature
run: |
if ! atcr-verify $IMAGE; then
echo "❌ Image is not signed"
exit 1
fi
5. Plan for Offline Scenarios#
For air-gapped environments, cache signature metadata and DID documents:
# Export signatures and DID docs for offline use
./export-verification-bundle.sh atcr.io/alice/myapp:latest > bundle.json
# In air-gapped environment
atcr-verify --offline --bundle bundle.json atcr.io/alice/myapp:latest
Next Steps#
- Try manual verification using the shell scripts above
- Set up admission webhook for your Kubernetes cluster
- Define trust policies for your organization
- Integrate into CI/CD pipelines
- Monitor signature coverage across your images
See Also#
- ATProto Signatures - Technical deep-dive
- SBOM Scanning - Similar ORAS artifact pattern
- Example Scripts - Working verification examples