Files
at-container-registry/docs/SIGNATURE_INTEGRATION.md
2026-01-04 21:10:29 -06:00

1211 lines
31 KiB
Markdown

# 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:
```bash
# 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:**
```bash
$ 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:
```bash
# 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:
```bash
# 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:
```bash
# 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:
```bash
$ 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
```bash
#!/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)
```go
// 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:
```yaml
# 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):
```go
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:**
```bash
# 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:
```yaml
# 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:**
```go
// 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]any{
"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:**
```bash
CGO_ENABLED=0 go build -o atproto-verifier ./cmd/ratify-atproto-plugin
```
2. **Create custom Ratify image:**
```dockerfile
FROM ghcr.io/ratify-project/ratify:latest
COPY atproto-verifier /.ratify/plugins/atproto-verifier
```
3. **Deploy Ratify:**
```yaml
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
```
4. **Configure Verifier:**
```yaml
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
```
5. **Use with Gatekeeper:**
```yaml
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](./INTEGRATION_STRATEGY.md#ratify-verifier-plugin)
---
### Option 4: OPA Gatekeeper External Data Provider ⭐
Use Gatekeeper's External Data Provider feature to verify ATProto signatures:
**Provider Service:**
```go
// 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]any `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]any, 0, len(req.Values))
for _, image := range req.Values {
result, err := verifier.Verify(context.Background(), image)
response := map[string]any{
"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:**
```yaml
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:**
```yaml
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):**
```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](./INTEGRATION_STRATEGY.md#opa-gatekeeper-external-provider)
---
### Option 5: OPA Gatekeeper
Use OPA for policy enforcement:
```yaml
# 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
```yaml
# .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
```yaml
# .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:
```go
// 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`):
```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:
```yaml
# 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
```bash
$ 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:**
```bash
curl -s "https://plc.directory/did:plc:alice123" | jq .
# Should return DID document with verificationMethod
```
**Check PDS connectivity:**
```bash
curl -s "https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=did:plc:alice123" | jq .
# Should return repository metadata
```
**Check record exists:**
```bash
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:
```bash
# 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:
```bash
kubectl label namespace production atcr-verify=enabled
```
### 2. Use Trust Policies
Don't blindly trust all signatures - define which DIDs you trust:
```yaml
trustedDIDs:
- did:plc:your-org-team
- did:plc:your-ci-system
```
### 3. Monitor Signature Coverage
Track which images have signatures:
```bash
# 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:
```yaml
# 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:
```bash
# 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
- [ATProto Signatures](./ATPROTO_SIGNATURES.md) - Technical deep-dive
- [SBOM Scanning](./SBOM_SCANNING.md) - Similar ORAS artifact pattern
- [Example Scripts](../examples/verification/) - Working verification examples