A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

background ensurecrew to prevent stalling oauth

evan.jarrett.net 7a005023 ff7bc131

verified
+452 -4
+4 -3
cmd/appview/serve.go
··· 314 314 } else { 315 315 slog.Debug("Updated profile with hold DID", "component", "appview/callback", "hold_did", holdDID) 316 316 } 317 - slog.Debug("Attempting crew registration", "component", "oauth/server", "did", did, "hold_did", holdDID) 318 - storage.EnsureCrewMembership(ctx, client, refresher, holdDID) 319 317 } else { 320 318 // Already a DID - use it 321 319 holdDID = profile.DefaultHold 322 320 } 323 321 // Register crew regardless of migration (outside the migration block) 322 + // Run in background to avoid blocking OAuth callback if hold is offline 324 323 slog.Debug("Attempting crew registration", "component", "appview/callback", "did", did, "hold_did", holdDID) 325 - storage.EnsureCrewMembership(ctx, client, refresher, holdDID) 324 + go func(ctx context.Context, client *atproto.Client, refresher *oauth.Refresher, holdDID string) { 325 + storage.EnsureCrewMembership(ctx, client, refresher, holdDID) 326 + }(ctx, client, refresher, holdDID) 326 327 327 328 } 328 329
+225
examples/plugins/gatekeeper-provider/main.go.temp
··· 1 + // Package main implements an OPA Gatekeeper External Data Provider for ATProto signature verification. 2 + package main 3 + 4 + import ( 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "log" 9 + "net/http" 10 + "os" 11 + "time" 12 + ) 13 + 14 + const ( 15 + // DefaultPort is the default HTTP port 16 + DefaultPort = "8080" 17 + 18 + // DefaultTrustPolicyPath is the default trust policy file path 19 + DefaultTrustPolicyPath = "/config/trust-policy.yaml" 20 + ) 21 + 22 + // Server is the HTTP server for the external data provider. 23 + type Server struct { 24 + verifier *Verifier 25 + port string 26 + httpServer *http.Server 27 + } 28 + 29 + // ProviderRequest is the request format from Gatekeeper. 30 + type ProviderRequest struct { 31 + Keys []string `json:"keys"` 32 + Values []string `json:"values"` 33 + } 34 + 35 + // ProviderResponse is the response format to Gatekeeper. 36 + type ProviderResponse struct { 37 + SystemError string `json:"system_error,omitempty"` 38 + Responses []map[string]interface{} `json:"responses"` 39 + } 40 + 41 + // VerificationResult holds the result of verifying a single image. 42 + type VerificationResult struct { 43 + Image string `json:"image"` 44 + Verified bool `json:"verified"` 45 + DID string `json:"did,omitempty"` 46 + Handle string `json:"handle,omitempty"` 47 + SignedAt time.Time `json:"signedAt,omitempty"` 48 + CommitCID string `json:"commitCid,omitempty"` 49 + Error string `json:"error,omitempty"` 50 + } 51 + 52 + // NewServer creates a new provider server. 53 + func NewServer(verifier *Verifier, port string) *Server { 54 + return &Server{ 55 + verifier: verifier, 56 + port: port, 57 + } 58 + } 59 + 60 + // Start starts the HTTP server. 61 + func (s *Server) Start() error { 62 + mux := http.NewServeMux() 63 + 64 + // Provider endpoint (called by Gatekeeper) 65 + mux.HandleFunc("/provide", s.handleProvide) 66 + 67 + // Health check endpoints 68 + mux.HandleFunc("/health", s.handleHealth) 69 + mux.HandleFunc("/ready", s.handleReady) 70 + 71 + // Metrics endpoint (Prometheus) 72 + // TODO: Implement metrics 73 + // mux.HandleFunc("/metrics", s.handleMetrics) 74 + 75 + s.httpServer = &http.Server{ 76 + Addr: ":" + s.port, 77 + Handler: mux, 78 + ReadTimeout: 10 * time.Second, 79 + WriteTimeout: 30 * time.Second, 80 + IdleTimeout: 60 * time.Second, 81 + } 82 + 83 + log.Printf("Starting ATProto signature verification provider on port %s", s.port) 84 + return s.httpServer.ListenAndServe() 85 + } 86 + 87 + // Stop gracefully stops the HTTP server. 88 + func (s *Server) Stop(ctx context.Context) error { 89 + if s.httpServer != nil { 90 + return s.httpServer.Shutdown(ctx) 91 + } 92 + return nil 93 + } 94 + 95 + // handleProvide handles the provider endpoint called by Gatekeeper. 96 + func (s *Server) handleProvide(w http.ResponseWriter, r *http.Request) { 97 + if r.Method != http.MethodPost { 98 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 99 + return 100 + } 101 + 102 + // Parse request 103 + var req ProviderRequest 104 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 105 + log.Printf("ERROR: failed to parse request: %v", err) 106 + http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 107 + return 108 + } 109 + 110 + log.Printf("INFO: received verification request for %d images", len(req.Values)) 111 + 112 + // Verify each image 113 + responses := make([]map[string]interface{}, 0, len(req.Values)) 114 + for _, image := range req.Values { 115 + result := s.verifyImage(r.Context(), image) 116 + responses = append(responses, structToMap(result)) 117 + } 118 + 119 + // Send response 120 + resp := ProviderResponse{ 121 + Responses: responses, 122 + } 123 + 124 + w.Header().Set("Content-Type", "application/json") 125 + if err := json.NewEncoder(w).Encode(resp); err != nil { 126 + log.Printf("ERROR: failed to encode response: %v", err) 127 + } 128 + } 129 + 130 + // verifyImage verifies a single image. 131 + func (s *Server) verifyImage(ctx context.Context, image string) VerificationResult { 132 + start := time.Now() 133 + log.Printf("INFO: verifying image: %s", image) 134 + 135 + // Call verifier 136 + verified, metadata, err := s.verifier.Verify(ctx, image) 137 + duration := time.Since(start) 138 + 139 + if err != nil { 140 + log.Printf("ERROR: verification failed for %s: %v (duration: %v)", image, err, duration) 141 + return VerificationResult{ 142 + Image: image, 143 + Verified: false, 144 + Error: err.Error(), 145 + } 146 + } 147 + 148 + if !verified { 149 + log.Printf("WARN: image %s failed verification (duration: %v)", image, duration) 150 + return VerificationResult{ 151 + Image: image, 152 + Verified: false, 153 + Error: "signature verification failed", 154 + } 155 + } 156 + 157 + log.Printf("INFO: image %s verified successfully (DID: %s, duration: %v)", 158 + image, metadata.DID, duration) 159 + 160 + return VerificationResult{ 161 + Image: image, 162 + Verified: true, 163 + DID: metadata.DID, 164 + Handle: metadata.Handle, 165 + SignedAt: metadata.SignedAt, 166 + CommitCID: metadata.CommitCID, 167 + } 168 + } 169 + 170 + // handleHealth handles health check requests. 171 + func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { 172 + w.Header().Set("Content-Type", "application/json") 173 + json.NewEncoder(w).Encode(map[string]string{ 174 + "status": "ok", 175 + "version": "1.0.0", 176 + }) 177 + } 178 + 179 + // handleReady handles readiness check requests. 180 + func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) { 181 + // TODO: Check dependencies (DID resolver, PDS connectivity) 182 + w.Header().Set("Content-Type", "application/json") 183 + json.NewEncoder(w).Encode(map[string]string{ 184 + "status": "ready", 185 + }) 186 + } 187 + 188 + // structToMap converts a struct to a map for JSON encoding. 189 + func structToMap(v interface{}) map[string]interface{} { 190 + data, _ := json.Marshal(v) 191 + var m map[string]interface{} 192 + json.Unmarshal(data, &m) 193 + return m 194 + } 195 + 196 + func main() { 197 + // Load configuration 198 + port := os.Getenv("HTTP_PORT") 199 + if port == "" { 200 + port = DefaultPort 201 + } 202 + 203 + trustPolicyPath := os.Getenv("TRUST_POLICY_PATH") 204 + if trustPolicyPath == "" { 205 + trustPolicyPath = DefaultTrustPolicyPath 206 + } 207 + 208 + // Create verifier 209 + verifier, err := NewVerifier(trustPolicyPath) 210 + if err != nil { 211 + log.Fatalf("FATAL: failed to create verifier: %v", err) 212 + } 213 + 214 + // Create server 215 + server := NewServer(verifier, port) 216 + 217 + // Start server 218 + if err := server.Start(); err != nil && err != http.ErrServerClosed { 219 + log.Fatalf("FATAL: server error: %v", err) 220 + } 221 + } 222 + 223 + // TODO: Implement verifier.go with ATProto signature verification logic 224 + // TODO: Implement resolver.go with DID resolution 225 + // TODO: Implement crypto.go with K-256 signature verification
+214
examples/plugins/ratify-verifier/verifier.go.temp
··· 1 + // Package atproto implements a Ratify verifier plugin for ATProto signatures. 2 + package atproto 3 + 4 + import ( 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "time" 9 + 10 + "github.com/ratify-project/ratify/pkg/common" 11 + "github.com/ratify-project/ratify/pkg/ocispecs" 12 + "github.com/ratify-project/ratify/pkg/referrerstore" 13 + "github.com/ratify-project/ratify/pkg/verifier" 14 + ) 15 + 16 + const ( 17 + // VerifierName is the name of this verifier 18 + VerifierName = "atproto" 19 + 20 + // VerifierType is the type of this verifier 21 + VerifierType = "atproto" 22 + 23 + // ATProtoSignatureArtifactType is the OCI artifact type for ATProto signatures 24 + ATProtoSignatureArtifactType = "application/vnd.atproto.signature.v1+json" 25 + ) 26 + 27 + // ATProtoVerifier implements the Ratify ReferenceVerifier interface for ATProto signatures. 28 + type ATProtoVerifier struct { 29 + name string 30 + config ATProtoConfig 31 + resolver *Resolver 32 + verifier *SignatureVerifier 33 + trustStore *TrustStore 34 + } 35 + 36 + // ATProtoConfig holds configuration for the ATProto verifier. 37 + type ATProtoConfig struct { 38 + // TrustPolicyPath is the path to the trust policy YAML file 39 + TrustPolicyPath string `json:"trustPolicyPath"` 40 + 41 + // DIDResolverTimeout is the timeout for DID resolution 42 + DIDResolverTimeout time.Duration `json:"didResolverTimeout"` 43 + 44 + // PDSTimeout is the timeout for PDS XRPC calls 45 + PDSTimeout time.Duration `json:"pdsTimeout"` 46 + 47 + // CacheEnabled enables caching of DID documents and public keys 48 + CacheEnabled bool `json:"cacheEnabled"` 49 + 50 + // CacheTTL is the cache TTL for DID documents and public keys 51 + CacheTTL time.Duration `json:"cacheTTL"` 52 + } 53 + 54 + // ATProtoSignature represents the ATProto signature metadata stored in the OCI artifact. 55 + type ATProtoSignature struct { 56 + Type string `json:"$type"` 57 + Version string `json:"version"` 58 + Subject struct { 59 + Digest string `json:"digest"` 60 + MediaType string `json:"mediaType"` 61 + } `json:"subject"` 62 + ATProto struct { 63 + DID string `json:"did"` 64 + Handle string `json:"handle"` 65 + PDSEndpoint string `json:"pdsEndpoint"` 66 + RecordURI string `json:"recordUri"` 67 + CommitCID string `json:"commitCid"` 68 + SignedAt time.Time `json:"signedAt"` 69 + } `json:"atproto"` 70 + Signature struct { 71 + Algorithm string `json:"algorithm"` 72 + KeyID string `json:"keyId"` 73 + PublicKeyMultibase string `json:"publicKeyMultibase"` 74 + } `json:"signature"` 75 + } 76 + 77 + // NewATProtoVerifier creates a new ATProto verifier instance. 78 + func NewATProtoVerifier(name string, config ATProtoConfig) (*ATProtoVerifier, error) { 79 + // Load trust policy 80 + trustStore, err := LoadTrustStore(config.TrustPolicyPath) 81 + if err != nil { 82 + return nil, fmt.Errorf("failed to load trust policy: %w", err) 83 + } 84 + 85 + // Create resolver with caching 86 + resolver := NewResolver(config.DIDResolverTimeout, config.CacheEnabled, config.CacheTTL) 87 + 88 + // Create signature verifier 89 + verifier := NewSignatureVerifier(config.PDSTimeout) 90 + 91 + return &ATProtoVerifier{ 92 + name: name, 93 + config: config, 94 + resolver: resolver, 95 + verifier: verifier, 96 + trustStore: trustStore, 97 + }, nil 98 + } 99 + 100 + // Name returns the name of this verifier. 101 + func (v *ATProtoVerifier) Name() string { 102 + return v.name 103 + } 104 + 105 + // Type returns the type of this verifier. 106 + func (v *ATProtoVerifier) Type() string { 107 + return VerifierType 108 + } 109 + 110 + // CanVerify returns true if this verifier can verify the given artifact type. 111 + func (v *ATProtoVerifier) CanVerify(artifactType string) bool { 112 + return artifactType == ATProtoSignatureArtifactType 113 + } 114 + 115 + // VerifyReference verifies an ATProto signature artifact. 116 + func (v *ATProtoVerifier) VerifyReference( 117 + ctx context.Context, 118 + subjectRef common.Reference, 119 + referenceDesc ocispecs.ReferenceDescriptor, 120 + store referrerstore.ReferrerStore, 121 + ) (verifier.VerifierResult, error) { 122 + // 1. Fetch signature blob from store 123 + sigBlob, err := store.GetBlobContent(ctx, subjectRef, referenceDesc.Digest) 124 + if err != nil { 125 + return v.failureResult(fmt.Sprintf("failed to fetch signature blob: %v", err)), err 126 + } 127 + 128 + // 2. Parse ATProto signature metadata 129 + var sigData ATProtoSignature 130 + if err := json.Unmarshal(sigBlob, &sigData); err != nil { 131 + return v.failureResult(fmt.Sprintf("failed to parse signature metadata: %v", err)), err 132 + } 133 + 134 + // Validate signature format 135 + if err := v.validateSignature(&sigData); err != nil { 136 + return v.failureResult(fmt.Sprintf("invalid signature format: %v", err)), err 137 + } 138 + 139 + // 3. Check trust policy first (fail fast if DID not trusted) 140 + if !v.trustStore.IsTrusted(sigData.ATProto.DID, time.Now()) { 141 + return v.failureResult(fmt.Sprintf("DID %s not in trusted list", sigData.ATProto.DID)), 142 + fmt.Errorf("untrusted DID") 143 + } 144 + 145 + // 4. Resolve DID to public key 146 + pubKey, err := v.resolver.ResolveDIDToPublicKey(ctx, sigData.ATProto.DID) 147 + if err != nil { 148 + return v.failureResult(fmt.Sprintf("failed to resolve DID: %v", err)), err 149 + } 150 + 151 + // 5. Fetch repository commit from PDS 152 + commit, err := v.verifier.FetchCommit(ctx, sigData.ATProto.PDSEndpoint, 153 + sigData.ATProto.DID, sigData.ATProto.CommitCID) 154 + if err != nil { 155 + return v.failureResult(fmt.Sprintf("failed to fetch commit: %v", err)), err 156 + } 157 + 158 + // 6. Verify K-256 signature 159 + if err := v.verifier.VerifySignature(pubKey, commit); err != nil { 160 + return v.failureResult(fmt.Sprintf("signature verification failed: %v", err)), err 161 + } 162 + 163 + // 7. Success - return detailed result 164 + return verifier.VerifierResult{ 165 + IsSuccess: true, 166 + Name: v.name, 167 + Type: v.Type(), 168 + Message: fmt.Sprintf("Successfully verified ATProto signature for DID %s", sigData.ATProto.DID), 169 + Extensions: map[string]interface{}{ 170 + "did": sigData.ATProto.DID, 171 + "handle": sigData.ATProto.Handle, 172 + "signedAt": sigData.ATProto.SignedAt, 173 + "commitCid": sigData.ATProto.CommitCID, 174 + "pdsEndpoint": sigData.ATProto.PDSEndpoint, 175 + }, 176 + }, nil 177 + } 178 + 179 + // validateSignature validates the signature metadata format. 180 + func (v *ATProtoVerifier) validateSignature(sig *ATProtoSignature) error { 181 + if sig.Type != "io.atcr.atproto.signature" { 182 + return fmt.Errorf("invalid signature type: %s", sig.Type) 183 + } 184 + if sig.ATProto.DID == "" { 185 + return fmt.Errorf("missing DID") 186 + } 187 + if sig.ATProto.PDSEndpoint == "" { 188 + return fmt.Errorf("missing PDS endpoint") 189 + } 190 + if sig.ATProto.CommitCID == "" { 191 + return fmt.Errorf("missing commit CID") 192 + } 193 + if sig.Signature.Algorithm != "ECDSA-K256-SHA256" { 194 + return fmt.Errorf("unsupported signature algorithm: %s", sig.Signature.Algorithm) 195 + } 196 + return nil 197 + } 198 + 199 + // failureResult creates a failure result with the given message. 200 + func (v *ATProtoVerifier) failureResult(message string) verifier.VerifierResult { 201 + return verifier.VerifierResult{ 202 + IsSuccess: false, 203 + Name: v.name, 204 + Type: v.Type(), 205 + Message: message, 206 + Extensions: map[string]interface{}{ 207 + "error": message, 208 + }, 209 + } 210 + } 211 + 212 + // TODO: Implement resolver.go with DID resolution logic 213 + // TODO: Implement crypto.go with K-256 signature verification 214 + // TODO: Implement config.go with trust policy loading
+4 -1
pkg/appview/middleware/registry.go
··· 154 154 // Auto-reconcile crew membership on first push/pull 155 155 // This ensures users can push immediately after docker login without web sign-in 156 156 // EnsureCrewMembership is best-effort and logs errors without failing the request 157 + // Run in background to avoid blocking registry operations if hold is offline 157 158 if holdDID != "" && nr.refresher != nil { 158 159 slog.Debug("Auto-reconciling crew membership", "component", "registry/middleware", "did", did, "hold_did", holdDID) 159 160 client := atproto.NewClient(pdsEndpoint, did, "") 160 - storage.EnsureCrewMembership(ctx, client, nr.refresher, holdDID) 161 + go func(ctx context.Context, client *atproto.Client, refresher *oauth.Refresher, holdDID string) { 162 + storage.EnsureCrewMembership(ctx, client, refresher, holdDID) 163 + }(ctx, client, nr.refresher, holdDID) 161 164 } 162 165 163 166 // Get service token for hold authentication
+5
pkg/appview/storage/crew.go
··· 6 6 "io" 7 7 "log/slog" 8 8 "net/http" 9 + "time" 9 10 10 11 "atcr.io/pkg/atproto" 11 12 "atcr.io/pkg/auth/oauth" ··· 59 60 // requestCrewMembership calls the hold's requestCrew endpoint 60 61 // The endpoint handles all authorization and duplicate checking internally 61 62 func requestCrewMembership(ctx context.Context, holdEndpoint, serviceToken string) error { 63 + // Add 5 second timeout to prevent hanging on offline holds 64 + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) 65 + defer cancel() 66 + 62 67 url := fmt.Sprintf("%s%s", holdEndpoint, atproto.HoldRequestCrew) 63 68 64 69 req, err := http.NewRequestWithContext(ctx, "POST", url, nil)