Files
at-container-registry/docs/SIGNATURE_INTEGRATION.md
2025-10-31 21:03:33 -05:00

31 KiB

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