A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.

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/signature annotation)
  • 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#

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.

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:

  1. Build plugin:
CGO_ENABLED=0 go build -o atproto-verifier ./cmd/ratify-atproto-plugin
  1. Create custom Ratify image:
FROM ghcr.io/ratify-project/ratify:latest
COPY atproto-verifier /.ratify/plugins/atproto-verifier
  1. 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
  1. 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
  1. 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:

  1. Image was pushed before signature creation was implemented
  2. Signature artifact creation failed
  3. 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#

  1. Try manual verification using the shell scripts above
  2. Set up admission webhook for your Kubernetes cluster
  3. Define trust policies for your organization
  4. Integrate into CI/CD pipelines
  5. Monitor signature coverage across your images

See Also#