mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-20 16:40:29 +00:00
1211 lines
31 KiB
Markdown
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
|