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

Configure Feed

Select the types of activity you want to include in your feed.

fix health checks on startup

+994 -515
+15
.env.appview.example
··· 72 72 # ATCR_LOG_FORMATTER=text 73 73 74 74 # ============================================================================== 75 + # Hold Health Check Configuration 76 + # ============================================================================== 77 + 78 + # How often to check health of hold endpoints in the background (default: 15m) 79 + # Queries database for unique hold endpoints and checks if they're reachable 80 + # Examples: 5m, 15m, 30m, 1h 81 + # ATCR_HEALTH_CHECK_INTERVAL=15m 82 + 83 + # How long to cache health check results (default: 15m) 84 + # Cached results avoid redundant health checks on page renders 85 + # Should be >= ATCR_HEALTH_CHECK_INTERVAL for efficiency 86 + # Examples: 15m, 30m, 1h 87 + # ATCR_HEALTH_CACHE_TTL=15m 88 + 89 + # ============================================================================== 75 90 # Jetstream Configuration (ATProto event streaming) 76 91 # ============================================================================== 77 92
+24 -4
cmd/appview/serve.go
··· 75 75 76 76 // Initialize hold health checker 77 77 fmt.Println("Initializing hold health checker...") 78 - cacheTTL := 15 * time.Minute // Cache TTL from user requirements 78 + 79 + // Parse health check cache TTL from environment (default: 15m) 80 + cacheTTL := 15 * time.Minute 81 + if cacheTTLStr := os.Getenv("ATCR_HEALTH_CACHE_TTL"); cacheTTLStr != "" { 82 + if parsed, err := time.ParseDuration(cacheTTLStr); err == nil { 83 + cacheTTL = parsed 84 + } else { 85 + fmt.Printf("Warning: Invalid ATCR_HEALTH_CACHE_TTL '%s', using default 15m\n", cacheTTLStr) 86 + } 87 + } 88 + 79 89 healthChecker := holdhealth.NewChecker(cacheTTL) 80 90 81 91 // Start background health check worker 82 - refreshInterval := 5 * time.Minute // Refresh every 5 minutes 92 + // Parse refresh interval from environment (default: 15m) 93 + refreshInterval := 15 * time.Minute 94 + if refreshIntervalStr := os.Getenv("ATCR_HEALTH_CHECK_INTERVAL"); refreshIntervalStr != "" { 95 + if parsed, err := time.ParseDuration(refreshIntervalStr); err == nil { 96 + refreshInterval = parsed 97 + } else { 98 + fmt.Printf("Warning: Invalid ATCR_HEALTH_CHECK_INTERVAL '%s', using default 15m\n", refreshIntervalStr) 99 + } 100 + } 101 + 102 + startupDelay := 5 * time.Second // Wait for hold services to start (Docker compose) 83 103 dbAdapter := holdhealth.NewDBAdapter(uiDatabase) 84 - healthWorker := holdhealth.NewWorker(healthChecker, dbAdapter, refreshInterval) 104 + healthWorker := holdhealth.NewWorkerWithStartupDelay(healthChecker, dbAdapter, refreshInterval, startupDelay) 85 105 86 106 // Create context for worker lifecycle management 87 107 workerCtx, workerCancel := context.WithCancel(context.Background()) 88 108 defer workerCancel() // Ensure context is cancelled on all exit paths 89 109 healthWorker.Start(workerCtx) 90 - fmt.Println("Hold health worker started (5min refresh interval, 15min cache TTL)") 110 + fmt.Printf("Hold health worker started (5s startup delay, %s refresh interval, %s cache TTL)\n", refreshInterval, cacheTTL) 91 111 92 112 // Initialize OAuth components 93 113 fmt.Println("Initializing OAuth components...")
+734 -510
docs/IMAGE_SIGNING.md
··· 1 1 # Image Signing with ATProto 2 2 3 - ATCR can support cryptographic signing of container images to ensure authenticity and integrity. This document explores different approaches and recommends a design based on Notary v2's plugin architecture adapted for ATProto. 3 + ATCR supports cryptographic signing of container images to ensure authenticity and integrity. Users have two options: 4 4 5 - ## Background: Why Not Cosign? 5 + 1. **Automatic signing (recommended)**: Credential helper signs images automatically on every push 6 + 2. **Manual signing**: Use standard Cosign tools yourself 6 7 7 - [Sigstore Cosign](https://github.com/sigstore/cosign) is the most popular OCI image signing tool, but has several incompatibilities with ATProto: 8 + Both approaches use the OCI Referrers API bridge for verification with standard tools (Cosign, Notary, Kubernetes admission controllers). 8 9 9 - ### 1. Key Format Mismatch 10 + ## Design Constraints 10 11 11 - **ATProto PDS keys:** 12 - - Format: secp256k1 (K256) for signing 13 - - Purpose: ATProto record signatures, DID authentication 14 - - Access: Private keys never leave the PDS server 15 - - Standard: ATProto specification 12 + ### Why Server-Side Signing Doesn't Work 16 13 17 - **Cosign expected keys:** 18 - - Format: ECDSA P-256, RSA, or Ed25519 19 - - Purpose: Image signing (not ATProto records) 20 - - Access: User-controlled private keys 21 - - Standard: Sigstore/PKIX 14 + It's tempting to implement automatic signing on the AppView or hold (like GitHub's automatic Cosign signing), but this breaks the fundamental trust model: 22 15 23 - **Problem:** Can't use PDS keys directly for Cosign signing - wrong curve, wrong access model, wrong security boundary. 16 + **The problem: Signing "on behalf of" isn't real signing** 24 17 25 - ### 2. No Direct PDS Key Access 18 + ``` 19 + ❌ AppView signs image → Proves "AppView vouches for this" 20 + ❌ Hold signs image → Proves "Hold vouches for this" 21 + ❌ PDS signs image → Proves "PDS vouches for this" 22 + ✅ Alice signs image → Proves "Alice created/approved this" 23 + ``` 26 24 27 - **Security model:** 28 - - PDS private keys are server-side secrets 29 - - Never exposed to clients (even authenticated users) 30 - - Used only by PDS for ATProto operations 31 - - Exposing them would compromise entire account security 25 + **Why GitHub can do it:** 26 + - GitHub Actions runs with your GitHub identity 27 + - OIDC token proves "this workflow runs as alice on GitHub" 28 + - Fulcio certificate authority issues cert based on that proof 29 + - Still "alice" signing, just via GitHub's infrastructure 32 30 33 - **Cosign requirement:** 34 - - Needs access to private key for signing operations 35 - - Expects user-controlled keys or KMS integration 31 + **Why ATCR can't replicate this:** 32 + - ATProto doesn't have OIDC/Fulcio equivalent 33 + - AppView can't sign "as alice" - only alice can 34 + - No secure server-side storage for user private keys 35 + - ATProto doesn't have encrypted record storage yet 36 + - Storing keys in AppView database = AppView controls keys, not alice 37 + - Hold's PDS has its own private key, but signing with it proves hold ownership, not user ownership 36 38 37 - **Problem:** Can't sign images client-side with PDS keys without fundamentally breaking ATProto security model. 39 + **Conclusion:** Signing must happen **client-side with user-controlled keys**. 38 40 39 - ### 3. Keyless Signing Complexity 41 + ### Why ATProto Record Signatures Aren't Sufficient 40 42 41 - Cosign supports "keyless" signing via OIDC + Fulcio CA: 43 + ATProto already signs all records stored in PDSs. When a manifest is stored as an `io.atcr.manifest` record, it includes: 42 44 43 - **What it requires:** 44 - - OIDC identity provider (Google, GitHub, etc.) 45 - - Fulcio certificate authority (issues short-lived certs) 46 - - Rekor transparency log (immutable signature log) 47 - - All infrastructure managed by Sigstore 45 + ```json 46 + { 47 + "uri": "at://did:plc:alice123/io.atcr.manifest/abc123", 48 + "cid": "bafyrei...", 49 + "value": { /* manifest data */ }, 50 + "sig": "..." // ← PDS signature over record 51 + } 52 + ``` 48 53 49 - **ATProto adaptation would need:** 50 - - **OIDC bridge**: Make ATProto DIDs look like OIDC identities 51 - - Map `did:plc:alice123` → OIDC claims 52 - - PDS as OIDC provider? (not in spec) 53 - - Requires custom OIDC server wrapping ATProto auth 54 - - **Fulcio adaptation**: Issue certs based on ATProto identities 55 - - Deploy and manage CA infrastructure 56 - - Handle DID resolution in cert issuance 57 - - Trust anchor distribution 58 - - **Rekor instance**: Public transparency log for signatures 59 - - High availability requirements 60 - - Storage and indexing at scale 61 - - Replication and backup 54 + **What this proves:** 55 + - ✅ Alice's PDS created and signed this record 56 + - ✅ Record hasn't been tampered with since signing 57 + - ✅ CID correctly represents the record content 62 58 63 - **Problem:** Too much infrastructure for ATCR to host and manage. Defeats the purpose of decentralized architecture. 59 + **What this doesn't prove:** 60 + - ❌ Alice personally approved this image 61 + - ❌ Alice's private key was involved (only PDS key) 64 62 65 - ### 4. Signature Storage 63 + **The gap:** 64 + - A compromised or malicious PDS could create fake manifest records and sign them validly 65 + - PDS operator could sign manifests without user's knowledge 66 + - No proof that the *user* (not just their PDS) approved the image 66 67 67 - **Cosign storage:** 68 - - OCI registry artifacts (signatures as ORAS manifests) 69 - - Stored alongside images in registry 68 + **For true image signing, we need:** 69 + - User-controlled private keys (not PDS keys) 70 + - Client-side signing (where user has key access) 71 + - Separate signature records proving user approval 70 72 71 - **ATCR ideal:** 72 - - Signatures in ATProto records (user's PDS) 73 - - Discoverable via ATProto queries 74 - - Integrated with ATProto's existing signature/verification model 73 + **Important nuance - PDS Trust Spectrum:** 75 74 76 - **Problem:** Would need to patch Cosign or run dual storage (OCI + ATProto) which creates consistency issues. 75 + While ATProto records are always signed by the PDS, this doesn't provide user-level signing for image verification: 77 76 78 - ### Conclusion: Cosign Doesn't Fit 77 + 1. **Self-hosted PDS with user-controlled keys:** 78 + - User runs their own PDS and controls PDS rotation keys 79 + - PDS signature ≈ user signature (trusted operator) 80 + - Still doesn't work with standard tools (Cosign/Notary) 79 81 80 - While Cosign is excellent for traditional registries, forcing it into ATProto would require: 81 - - Breaking ATProto security model (exposing PDS keys), OR 82 - - Building massive OIDC/Fulcio/Rekor infrastructure, OR 83 - - Running parallel storage systems with consistency problems 82 + 2. **Shared/managed PDS (e.g., Bluesky):** 83 + - PDS operated by third party (bsky.social) 84 + - Auto-generated keys controlled by operator 85 + - User doesn't have access to PDS rotation keys 86 + - PDS signature ≠ user signature 84 87 85 - **Better approach:** Use a more flexible signing framework designed for extensibility. 88 + **For ATCR:** 89 + - Credential helper signing works for all users (self-hosted or shared PDS) 90 + - Provides user-controlled keys separate from PDS keys 91 + - Works with standard verification tools via OCI Referrers API bridge 86 92 87 - ## Notary v2: Plugin-Based Architecture 93 + ## Signing Options 88 94 89 - [Notary v2](https://notaryproject.dev/) (also called "Notation" or "Notary Project") is a CNCF signature specification with a plugin architecture that fits ATProto better. 95 + ### Option 1: Automatic Signing (Recommended) 90 96 91 - ### Why Notary v2? 97 + The credential helper automatically signs images on every push - no extra commands needed. 98 + 99 + **How it works:** 100 + - Credential helper runs on every `docker push` for authentication 101 + - Extended to also sign the manifest digest with user's private key 102 + - Private key stored securely in OS keychain 103 + - Signature sent to AppView and stored in ATProto 104 + - Completely transparent to the user 92 105 93 - **Flexible plugin system:** 94 - - **Trust store plugins**: Custom key resolution (e.g., from ATProto records) 95 - - **Signature plugins**: Custom signature storage (e.g., in PDS) 96 - - **Verification plugins**: Custom verification logic 97 - - Plugins written in any language, communicate via stdio 106 + ### Architecture 98 107 99 - **Multiple key types supported:** 100 - - ECDSA, RSA, Ed25519 out of box 101 - - Can support custom key types via plugins 102 - - Signature envelope format is extensible 108 + ``` 109 + ┌─────────────────────────────────────────────────────┐ 110 + │ docker push atcr.io/alice/myapp:latest │ 111 + └────────────────────┬────────────────────────────────┘ 112 + 113 + ┌─────────────────────────────────────────────────────┐ 114 + │ docker-credential-atcr (runs automatically) │ 115 + │ │ 116 + │ 1. Authenticate to AppView (OAuth) │ 117 + │ 2. Get registry JWT │ 118 + │ 3. Sign manifest digest with local private key ← NEW 119 + │ 4. Send signature to AppView ← NEW 120 + │ │ 121 + │ Private key stored in OS keychain │ 122 + │ (macOS Keychain, Windows Credential Manager, etc.) │ 123 + └────────────────────┬────────────────────────────────┘ 124 + 125 + ┌─────────────────────────────────────────────────────┐ 126 + │ AppView │ 127 + │ │ 128 + │ 1. Receives signature from credential helper │ 129 + │ 2. Stores in user's PDS (io.atcr.signature) │ 130 + │ │ 131 + │ OR stores in hold's PDS for BYOS scenarios │ 132 + └─────────────────────────────────────────────────────┘ 133 + ``` 103 134 104 - **Designed for extensibility:** 105 - - Not tied to specific PKI (unlike Cosign/Sigstore) 106 - - Trust policies are configurable 107 - - Storage backend is pluggable 108 - - Works with custom identity systems 135 + **User experience:** 109 136 110 - **Standard CLI:** 111 - - `notation sign` / `notation verify` commands 112 - - Users don't need to learn new tools 113 - - Integration with Docker/containerd 137 + ```bash 138 + # One-time setup 139 + docker login atcr.io 140 + # → Credential helper generates ECDSA key pair 141 + # → Private key stored in OS keychain 142 + # → Public key published to user's PDS 114 143 115 - ### Notary v2 Architecture 144 + # Every push (automatic signing) 145 + docker push atcr.io/alice/myapp:latest 146 + # → Image pushed 147 + # → Automatically signed by credential helper 148 + # → No extra commands! 116 149 150 + # Verification (standard Cosign) 151 + cosign verify atcr.io/alice/myapp:latest --key alice.pub 117 152 ``` 118 - ┌─────────────────────┐ 119 - │ notation CLI │ User signs/verifies images 120 - └──────────┬──────────┘ 121 - 122 - ├─────────────────────────────────────┐ 123 - │ │ 124 - ┌──────────▼─────────┐ ┌───────────▼──────────┐ 125 - │ Signing Plugin │ │ Trust Store Plugin │ 126 - │ │ │ │ 127 - │ - Read private key │ │ - Resolve DID → PDS │ 128 - │ - Generate sig │ │ - Fetch public keys │ 129 - │ - Store in PDS │ │ - Verify trust │ 130 - └────────────────────┘ └──────────────────────┘ 131 - │ │ 132 - ▼ ▼ 133 - ┌─────────────────────────────────────────────────────────┐ 134 - │ User's PDS (ATProto) │ 135 - │ │ 136 - │ io.atcr.signing.key (public keys) │ 137 - │ io.atcr.signature (signatures) │ 138 - └─────────────────────────────────────────────────────────┘ 139 - ``` 153 + 154 + ### Option 2: Manual Signing (DIY) 155 + 156 + Use standard Cosign tools yourself if you prefer manual control. 157 + 158 + **How it works:** 159 + - You manage your own signing keys 160 + - You run `cosign sign` manually after pushing 161 + - Signatures stored in ATProto via OCI Referrers API 162 + - Full control over signing workflow 140 163 141 - ## Proposed Design: ATProto Signing 164 + **User experience:** 142 165 143 - ### Key Management 166 + ```bash 167 + # Push image 168 + docker push atcr.io/alice/myapp:latest 144 169 145 - **Separate signing keys from PDS keys:** 170 + # Sign manually with Cosign 171 + cosign sign atcr.io/alice/myapp:latest --key cosign.key 146 172 147 - 1. **User generates signing key pair locally:** 148 - ```bash 149 - notation key generate --id alice-signing-key --type ecdsa 150 - # Or: --type ed25519, --type rsa 151 - ``` 173 + # Cosign stores signature via registry's OCI API 174 + # AppView receives signature and stores in ATProto 152 175 153 - 2. **Public key published to ATProto:** 154 - ```json 155 - { 156 - "$type": "io.atcr.signing.key", 157 - "keyId": "alice-signing-key", 158 - "keyType": "ecdsa-p256", 159 - "publicKey": "-----BEGIN PUBLIC KEY-----\nMFkw...", 160 - "validFrom": "2025-10-20T12:00:00Z", 161 - "expiresAt": "2026-10-20T12:00:00Z", 162 - "revoked": false, 163 - "createdAt": "2025-10-20T12:00:00Z" 164 - } 165 - ``` 176 + # Verification (same as automatic) 177 + cosign verify atcr.io/alice/myapp:latest --key cosign.pub 178 + ``` 166 179 167 - 3. **Private key stored locally:** 168 - - Docker credential store 169 - - OS keychain (macOS Keychain, Windows Credential Manager) 170 - - File with restrictive permissions 171 - - Hardware security module (future) 180 + **When to use:** 181 + - Need specific signing workflows (e.g., CI/CD pipelines) 182 + - Want to use hardware tokens (YubiKey) 183 + - Prefer manual control over automatic signing 184 + - Already using Cosign in your organization 172 185 173 - **Why separate keys?** 174 - - ✅ No need to access PDS private keys 175 - - ✅ Standard key formats (ECDSA, Ed25519, RSA) 176 - - ✅ User controls key lifecycle 177 - - ✅ Can use hardware tokens (YubiKey, etc.) 178 - - ✅ Security boundary separation (signing ≠ identity) 179 - - ✅ Key rotation without changing DID 186 + ### Key Management 180 187 181 - ### Signing Flow 188 + **Key generation (first run):** 189 + 1. Credential helper checks for existing signing key in OS keychain 190 + 2. If not found, generates new ECDSA P-256 key pair (or Ed25519) 191 + 3. Stores private key in OS keychain with access control 192 + 4. Derives public key for publishing 182 193 194 + **Public key publishing:** 195 + ```json 196 + { 197 + "$type": "io.atcr.signing.key", 198 + "keyId": "credential-helper-default", 199 + "keyType": "ecdsa-p256", 200 + "publicKey": "-----BEGIN PUBLIC KEY-----\nMFkw...", 201 + "validFrom": "2025-10-20T12:00:00Z", 202 + "expiresAt": null, 203 + "revoked": false, 204 + "purpose": ["image-signing"], 205 + "deviceId": "alice-macbook-pro", 206 + "createdAt": "2025-10-20T12:00:00Z" 207 + } 183 208 ``` 184 - 1. User: notation sign atcr.io/alice/myapp:latest --key alice-signing-key 185 209 186 - 2. notation-atproto plugin: 187 - a. Resolve image → manifest digest 188 - b. Read private key from local keystore 189 - c. Generate signature over manifest digest 190 - d. Get OAuth token for alice's PDS 191 - e. Create signature record in alice's PDS 210 + **Record stored in:** User's PDS at `io.atcr.signing.key/credential-helper-default` 192 211 193 - 3. Signature stored in alice's PDS: 212 + **Key storage locations:** 213 + - **macOS:** Keychain Access (secure enclave on modern Macs) 214 + - **Windows:** Credential Manager / Windows Data Protection API 215 + - **Linux:** Secret Service API (gnome-keyring, kwallet) 216 + - **Fallback:** Encrypted file with restrictive permissions (0600) 217 + 218 + ### Signing Flow 219 + 220 + ``` 221 + 1. docker push atcr.io/alice/myapp:latest 222 + 223 + 2. Docker daemon calls credential helper: 224 + docker-credential-atcr get atcr.io 225 + 226 + 3. Credential helper flow: 227 + a. Authenticate via OAuth (existing) 228 + b. Receive registry JWT from AppView (existing) 229 + c. Fetch manifest digest from registry (NEW) 230 + d. Load private key from OS keychain (NEW) 231 + e. Sign manifest digest (NEW) 232 + f. Send signature to AppView via XRPC (NEW) 233 + 234 + 4. AppView stores signature: 194 235 { 195 236 "$type": "io.atcr.signature", 196 237 "repository": "alice/myapp", 197 238 "digest": "sha256:abc123...", 198 - "signature": "MEUCIQDx...", // base64 signature bytes 199 - "keyId": "alice-signing-key", 239 + "signature": "MEUCIQDx...", 240 + "keyId": "credential-helper-default", 200 241 "signatureAlgorithm": "ecdsa-p256-sha256", 201 242 "signedAt": "2025-10-20T12:34:56Z" 202 243 } 203 - 204 - 4. Record key: sha256 of (digest + keyId) for deduplication 244 + 245 + 5. Return registry JWT to Docker 246 + 247 + 6. Docker proceeds with push 205 248 ``` 206 249 207 - ### Verification Flow 208 - 209 - ``` 210 - 1. User: notation verify atcr.io/alice/myapp:latest 250 + ### Signature Storage 211 251 212 - 2. notation-atproto plugin: 213 - a. Resolve "alice" → did:plc:alice123 → pds.alice.com 214 - b. Fetch manifest digest: sha256:abc123 215 - c. Query alice's PDS for signatures: 216 - GET /xrpc/com.atproto.repo.listRecords? 217 - repo=did:plc:alice123& 218 - collection=io.atcr.signature 219 - d. Filter records matching digest: sha256:abc123 220 - e. For each signature: 221 - - Fetch public key from io.atcr.signing.key record 222 - - Check key not revoked, not expired 223 - - Verify signature bytes over digest 224 - - Check trust policy (is this key trusted?) 252 + **Option 1: User's PDS (Default)** 253 + - Signature stored in alice's PDS 254 + - Collection: `io.atcr.signature` 255 + - Discoverable via alice's ATProto repo 256 + - User owns all signing metadata 225 257 226 - 3. Trust policy evaluation: 227 - - Signature valid cryptographically? ✓ 228 - - Key belongs to image owner (alice)? ✓ 229 - - Key not revoked? ✓ 230 - - Key not expired? ✓ 231 - - Trust policy satisfied? ✓ 258 + **Option 2: Hold's PDS (BYOS)** 259 + - Signature stored in hold's embedded PDS 260 + - Useful for shared holds with multiple users 261 + - Hold acts as signature repository 262 + - Parallel to SBOM storage model 232 263 233 - 4. Output: Verification succeeded ✓ 264 + **Decision logic:** 265 + ```go 266 + // In AppView signature handler 267 + if manifest.HoldDid != "" && manifest.HoldDid != appview.DefaultHoldDid { 268 + // BYOS scenario - store in hold's PDS 269 + storeSignatureInHold(manifest.HoldDid, signature) 270 + } else { 271 + // Default - store in user's PDS 272 + storeSignatureInUserPDS(userDid, signature) 273 + } 234 274 ``` 235 275 236 - ### Trust Policies 276 + ## Signature Format 237 277 238 - Notary v2 uses trust policies to define what signatures are required: 278 + Signatures are stored in a simple format in ATProto and transformed to Cosign-compatible format when served via the OCI Referrers API: 239 279 280 + **ATProto storage format:** 240 281 ```json 241 282 { 242 - "version": "1.0", 243 - "trustPolicies": [ 244 - { 245 - "name": "atcr-images", 246 - "registryScopes": ["atcr.io/*/*"], 247 - "signatureVerification": { 248 - "level": "strict" 249 - }, 250 - "trustStores": ["atproto:default"], 251 - "trustedIdentities": [ 252 - "did:plc:*" // Trust any ATProto DID 253 - ] 254 - } 255 - ] 283 + "$type": "io.atcr.signature", 284 + "repository": "alice/myapp", 285 + "digest": "sha256:abc123...", 286 + "signature": "base64-encoded-signature-bytes", 287 + "keyId": "credential-helper-default", 288 + "signatureAlgorithm": "ecdsa-p256-sha256", 289 + "signedAt": "2025-10-20T12:34:56Z", 290 + "format": "simple" 256 291 } 257 292 ``` 258 293 259 - **Policy options:** 260 - - `level: strict` - Signature required, verification must pass 261 - - `level: permissive` - Signature optional, but verified if present 262 - - `level: audit` - Signature logged but doesn't block 263 - - `level: skip` - No verification 294 + **OCI Referrers format (served by AppView):** 295 + ```json 296 + { 297 + "schemaVersion": 2, 298 + "mediaType": "application/vnd.oci.image.index.v1+json", 299 + "manifests": [{ 300 + "mediaType": "application/vnd.oci.image.manifest.v1+json", 301 + "digest": "sha256:...", 302 + "artifactType": "application/vnd.dev.cosign.simplesigning.v1+json", 303 + "annotations": { 304 + "dev.sigstore.cosign.signature": "MEUCIQDx...", 305 + "io.atcr.keyId": "credential-helper-default", 306 + "io.atcr.signedAt": "2025-10-20T12:34:56Z" 307 + } 308 + }] 309 + } 310 + ``` 264 311 265 - **Trust store resolution:** 266 - - `atproto:default` - Use ATProto plugin to resolve keys 267 - - Plugin queries user's PDS for `io.atcr.signing.key` records 268 - - Verifies key is owned by the image owner (DID match) 312 + This allows: 313 + - Simple storage in ATProto 314 + - Compatible with Cosign verification 315 + - No duplicate storage needed 269 316 270 - ### ATProto Records 317 + ## ATProto Records 271 318 272 - **io.atcr.signing.key** - Public signing keys 319 + ### io.atcr.signing.key - Public Signing Keys 273 320 274 321 ```json 275 322 { 276 323 "$type": "io.atcr.signing.key", 277 - "keyId": "alice-signing-key", 324 + "keyId": "credential-helper-default", 278 325 "keyType": "ecdsa-p256", 279 326 "publicKey": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZI...", 280 327 "validFrom": "2025-10-20T12:00:00Z", 281 328 "expiresAt": "2026-10-20T12:00:00Z", 282 329 "revoked": false, 283 330 "purpose": ["image-signing"], 331 + "deviceId": "alice-macbook-pro", 332 + "comment": "Generated by docker-credential-atcr", 284 333 "createdAt": "2025-10-20T12:00:00Z" 285 334 } 286 335 ``` ··· 288 337 **Record key:** `keyId` (user-chosen identifier) 289 338 290 339 **Fields:** 291 - - `keyId`: Unique identifier for this key 340 + - `keyId`: Unique identifier (e.g., `credential-helper-default`, `ci-key-1`) 292 341 - `keyType`: Algorithm (ecdsa-p256, ed25519, rsa-2048, rsa-4096) 293 342 - `publicKey`: PEM-encoded public key 294 343 - `validFrom`: Key becomes valid at this time 295 - - `expiresAt`: Key expires at this time (null = no expiry) 296 - - `revoked`: Key has been revoked (true/false) 297 - - `purpose`: Array of purposes (image-signing, sbom-signing, etc.) 344 + - `expiresAt`: Key expires (null = no expiry) 345 + - `revoked`: Revocation status 346 + - `purpose`: Key purposes (image-signing, sbom-signing, etc.) 347 + - `deviceId`: Optional device identifier 348 + - `comment`: Optional human-readable comment 298 349 299 - **io.atcr.signature** - Image signatures 350 + ### io.atcr.signature - Image Signatures 300 351 301 352 ```json 302 353 { ··· 304 355 "repository": "alice/myapp", 305 356 "digest": "sha256:abc123...", 306 357 "signature": "MEUCIQDxH7...", 307 - "keyId": "alice-signing-key", 358 + "keyId": "credential-helper-default", 308 359 "signatureAlgorithm": "ecdsa-p256-sha256", 309 360 "signedAt": "2025-10-20T12:34:56Z", 361 + "format": "simple", 310 362 "createdAt": "2025-10-20T12:34:56Z" 311 363 } 312 364 ``` ··· 315 367 316 368 **Fields:** 317 369 - `repository`: Image repository (alice/myapp) 318 - - `digest`: Manifest digest being signed 370 + - `digest`: Manifest digest being signed (sha256:...) 319 371 - `signature`: Base64-encoded signature bytes 320 372 - `keyId`: Reference to signing key record 321 - - `signatureAlgorithm`: Algorithm used for signing 322 - - `signedAt`: When signature was created 373 + - `signatureAlgorithm`: Algorithm used 374 + - `signedAt`: Timestamp of signature creation 375 + - `format`: Signature format (simple, cosign, notary) 376 + 377 + ## Verification 378 + 379 + Image signatures are verified using standard tools (Cosign, Notary) via the OCI Referrers API bridge. AppView transparently serves ATProto signatures as OCI artifacts, so verification "just works" with existing tooling. 380 + 381 + ### Integration with Docker/Kubernetes Workflows 382 + 383 + **The challenge:** Cosign and Notary plugins are for **key management** (custom KMS, HSMs), not **signature storage**. Both tools expect signatures stored as OCI artifacts in the registry itself. 384 + 385 + **Reality check:** 386 + - Cosign looks for signatures as OCI referrers or attached manifests 387 + - Notary looks for signatures in registry's `_notary` endpoint 388 + - Kubernetes admission controllers (Sigstore Policy Controller, Ratify) use these tools 389 + - They won't find signatures stored only in ATProto 323 390 324 - ### Plugin Implementation 391 + **The solution:** AppView implements the **OCI Referrers API** and serves ATProto signatures as OCI artifacts on-demand. 392 + 393 + ### How It Works: OCI Referrers API Bridge 325 394 326 - **notation-atproto** - Notary v2 plugin for ATProto 395 + When Cosign/Notary verify an image, they call the OCI Referrers API: 327 396 328 - **Trust store plugin:** 397 + ``` 398 + cosign verify atcr.io/alice/myapp:latest 399 + 400 + GET /v2/alice/myapp/referrers/sha256:abc123 401 + 402 + AppView: 403 + 1. Queries alice's PDS for io.atcr.signature records 404 + 2. Filters signatures matching digest sha256:abc123 405 + 3. Transforms to OCI referrers format 406 + 4. Returns as JSON 407 + 408 + Cosign receives OCI referrer manifest 409 + 410 + Verifies signature (works normally) 411 + ``` 412 + 413 + **AppView endpoint implementation:** 414 + 329 415 ```go 330 - // Implements: notation trust store plugin spec 331 - // https://notaryproject.dev/docs/user-guides/how-to/plugin-management/ 416 + // GET /v2/{owner}/{repo}/referrers/{digest} 417 + func (h *Handler) GetReferrers(w http.ResponseWriter, r *http.Request) { 418 + owner := mux.Vars(r)["owner"] 419 + digest := mux.Vars(r)["digest"] 332 420 333 - type ATProtoTrustStore struct { 334 - resolver *atproto.Resolver 335 - client *atproto.Client 336 - } 421 + // 1. Resolve owner → DID → PDS 422 + did, pds, err := h.resolver.ResolveIdentity(owner) 337 423 338 - // GetKeys resolves public keys for a given identity (DID) 339 - func (t *ATProtoTrustStore) GetKeys(did string) ([]PublicKey, error) { 340 - // 1. Resolve DID → PDS endpoint 341 - pds, err := t.resolver.ResolvePDS(did) 424 + // 2. Query PDS for signatures matching digest 425 + signatures, err := h.atproto.ListRecords(pds, did, "io.atcr.signature") 426 + filtered := filterByDigest(signatures, digest) 342 427 343 - // 2. Query PDS for io.atcr.signing.key records 344 - records, err := t.client.ListRecords(pds, did, "io.atcr.signing.key") 428 + // 3. Transform to OCI Index format 429 + index := &ocispec.Index{ 430 + SchemaVersion: 2, 431 + MediaType: ocispec.MediaTypeImageIndex, 432 + Manifests: []ocispec.Descriptor{}, 433 + } 345 434 346 - // 3. Filter active keys (not revoked, not expired) 347 - keys := []PublicKey{} 348 - for _, record := range records { 349 - if !record.Revoked && !record.Expired() { 350 - keys = append(keys, ParsePublicKey(record.PublicKey)) 351 - } 435 + for _, sig := range filtered { 436 + index.Manifests = append(index.Manifests, ocispec.Descriptor{ 437 + MediaType: "application/vnd.oci.image.manifest.v1+json", 438 + Digest: sig.Digest, 439 + Size: sig.Size, 440 + ArtifactType: "application/vnd.dev.cosign.simplesigning.v1+json", 441 + Annotations: map[string]string{ 442 + "dev.sigstore.cosign.signature": sig.Signature, 443 + "io.atcr.keyId": sig.KeyId, 444 + "io.atcr.signedAt": sig.SignedAt, 445 + "io.atcr.source": fmt.Sprintf("at://%s/io.atcr.signature/%s", did, sig.Rkey), 446 + }, 447 + }) 352 448 } 353 449 354 - return keys, nil 450 + // 4. Return as JSON 451 + w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) 452 + json.NewEncoder(w).Encode(index) 355 453 } 356 454 ``` 357 455 358 - **Signature store plugin:** 359 - ```go 360 - // Store signature in user's PDS 361 - func (s *ATProtoSignatureStore) StoreSignature(sig Signature) error { 362 - // 1. Get OAuth token for user's PDS 363 - token, err := s.oauthClient.GetToken() 456 + **Benefits:** 457 + - ✅ **No dual storage** - signatures only in ATProto 458 + - ✅ **Standard tools work** - Cosign, Notary, Kubernetes admission controllers 459 + - ✅ **Single source of truth** - ATProto PDS 460 + - ✅ **On-demand transformation** - only when needed 461 + - ✅ **Offline verification** - can cache public keys 364 462 365 - // 2. Create signature record 366 - record := SignatureRecord{ 367 - Type: "io.atcr.signature", 368 - Repository: sig.Repository, 369 - Digest: sig.Digest, 370 - Signature: base64.Encode(sig.Bytes), 371 - KeyId: sig.KeyId, 372 - SignatureAlgorithm: sig.Algorithm, 373 - SignedAt: time.Now(), 374 - } 463 + **Trade-offs:** 464 + - ⚠️ AppView must be reachable during verification (but already required for image pulls) 465 + - ⚠️ Transformation overhead (minimal - just JSON formatting) 375 466 376 - // 3. Generate record key (hash of digest + keyId) 377 - rkey := sha256.Sum256([]byte(sig.Digest + sig.KeyId)) 467 + ### Alternative Approaches 378 468 379 - // 4. Write to PDS 380 - err = s.client.PutRecord(pds, did, "io.atcr.signature", hex.Encode(rkey), record) 469 + #### Option 1: Dual Storage (Not Recommended) 381 470 382 - return err 383 - } 471 + Store signatures in BOTH ATProto AND OCI registry: 384 472 385 - // Retrieve signatures for a digest 386 - func (s *ATProtoSignatureStore) GetSignatures(did, digest string) ([]Signature, error) { 387 - // Query PDS for matching signatures 388 - records, err := s.client.ListRecords(pds, did, "io.atcr.signature") 473 + ```go 474 + // In credential helper or AppView 475 + func StoreSignature(sig Signature) error { 476 + // 1. Store in ATProto (user's PDS or hold's PDS) 477 + err := storeInATProto(sig) 389 478 390 - // Filter by digest 391 - sigs := []Signature{} 392 - for _, record := range records { 393 - if record.Digest == digest { 394 - sigs = append(sigs, ParseSignature(record)) 395 - } 396 - } 479 + // 2. ALSO store as OCI artifact in registry 480 + err = storeAsOCIReferrer(sig) 397 481 398 - return sigs, nil 482 + return err 399 483 } 400 484 ``` 401 485 402 - **Plugin installation:** 403 - ```bash 404 - # Install notation CLI 405 - brew install notation 406 - 407 - # Install ATProto plugin 408 - notation plugin install notation-atproto --version v1.0.0 409 - 410 - # Configure trust policy 411 - cat > ~/.config/notation/trustpolicy.json <<EOF 486 + **OCI Referrer format:** 487 + ```json 412 488 { 413 - "version": "1.0", 414 - "trustPolicies": [ 415 - { 416 - "name": "atcr-images", 417 - "registryScopes": ["atcr.io/*/*"], 418 - "signatureVerification": {"level": "strict"}, 419 - "trustStores": ["atproto:default"], 420 - "trustedIdentities": ["did:plc:*"] 489 + "schemaVersion": 2, 490 + "mediaType": "application/vnd.oci.image.manifest.v1+json", 491 + "artifactType": "application/vnd.dev.cosign.simplesigning.v1+json", 492 + "subject": { 493 + "digest": "sha256:abc123...", 494 + "mediaType": "application/vnd.oci.image.manifest.v1+json" 495 + }, 496 + "layers": [{ 497 + "mediaType": "application/vnd.dev.cosign.simplesigning.v1+json", 498 + "digest": "sha256:sig...", 499 + "annotations": { 500 + "dev.sigstore.cosign.signature": "MEUCIQDx...", 501 + "io.atcr.source": "atproto://did:plc:alice123/io.atcr.signature/..." 421 502 } 422 - ] 503 + }] 423 504 } 424 - EOF 425 505 ``` 426 506 427 - ## User Workflows 428 - 429 - ### Initial Setup 430 - 431 - ```bash 432 - # 1. Generate signing key pair 433 - notation key generate --id alice-signing-key --type ecdsa 507 + **Benefits:** 508 + - ✅ Works with standard Cosign verification 509 + - ✅ Kubernetes admission controllers work out of box 510 + - ✅ ATProto signatures still available for discovery 511 + - ✅ Cross-reference via `io.atcr.source` annotation 434 512 435 - # Private key stored in: ~/.config/notation/keys/ 436 - # Public key extracted by plugin 513 + **Trade-offs:** 514 + - ❌ Duplicate storage (ATProto + OCI) 515 + - ❌ Consistency issues (what if one write fails?) 516 + - ❌ Signatures tied to specific registry 437 517 438 - # 2. Publish public key to PDS 439 - notation-atproto key publish alice-signing-key 518 + #### Option 2: Custom Admission Controller 440 519 441 - # Plugin uploads io.atcr.signing.key record to alice's PDS 442 - # Requires OAuth authentication to alice's PDS 520 + Write Kubernetes admission controller that understands ATProto: 443 521 444 - # 3. Verify key is published 445 - notation-atproto key list 446 - 447 - # Output: 448 - # alice-signing-key (ecdsa-p256) - Active 449 - # Published: 2025-10-20T12:00:00Z 450 - # Expires: 2026-10-20T12:00:00Z 451 - # DID: did:plc:alice123 522 + ```yaml 523 + # admission-controller deployment 524 + apiVersion: v1 525 + kind: ConfigMap 526 + metadata: 527 + name: atcr-policy 528 + data: 529 + policy.yaml: | 530 + policies: 531 + - name: require-atcr-signatures 532 + images: 533 + - "atcr.io/*/*" 534 + verification: 535 + method: atproto 536 + requireSignature: true 452 537 ``` 453 538 454 - ### Signing Images 539 + **Benefits:** 540 + - ✅ Native ATProto support 541 + - ✅ No OCI conversion needed 542 + - ✅ Can enforce ATCR-specific policies 455 543 456 - ```bash 457 - # Sign an image after pushing 458 - docker push atcr.io/alice/myapp:latest 544 + **Trade-offs:** 545 + - ❌ Doesn't work with standard tools (Cosign, Notary) 546 + - ❌ Additional infrastructure to maintain 547 + - ❌ Limited ecosystem integration 459 548 460 - notation sign atcr.io/alice/myapp:latest \ 461 - --key alice-signing-key \ 462 - --plugin atproto 549 + #### Recommendation 463 550 464 - # Plugin: 465 - # 1. Reads private key from ~/.config/notation/keys/ 466 - # 2. Signs manifest digest 467 - # 3. Uploads signature to alice's PDS (io.atcr.signature record) 468 - # 4. Returns success 551 + **Primary approach: OCI Referrers API Bridge** 552 + - Implement `/v2/{owner}/{repo}/referrers/{digest}` in AppView 553 + - Query ATProto on-demand and transform to OCI format 554 + - Works with Cosign, Notary, Kubernetes admission controllers 555 + - No duplicate storage, single source of truth 469 556 470 - # Output: 471 - # Successfully signed atcr.io/alice/myapp:latest 472 - # Signature stored in PDS: did:plc:alice123 473 - ``` 557 + **Why this works:** 558 + - Cosign/Notary just make HTTP GET requests to the registry 559 + - AppView is already the registry - just add one endpoint 560 + - Transformation is simple (ATProto record → OCI descriptor) 561 + - Signatures stay in ATProto where they belong 474 562 475 - ### Verifying Images 563 + ### Cosign Verification (OCI Referrers API) 476 564 477 565 ```bash 478 - # Verify before running 479 - notation verify atcr.io/alice/myapp:latest 566 + # Standard Cosign works out of the box: 567 + cosign verify atcr.io/alice/myapp:latest \ 568 + --key <(atcr-cli key export alice credential-helper-default) 480 569 481 - # Plugin: 482 - # 1. Resolves "alice" → did:plc:alice123 → pds.alice.com 483 - # 2. Fetches manifest digest 484 - # 3. Queries alice's PDS for signatures 485 - # 4. Fetches public key from io.atcr.signing.key 486 - # 5. Verifies signature cryptographically 487 - # 6. Checks trust policy 570 + # What happens: 571 + # 1. Cosign queries: GET /v2/alice/myapp/referrers/sha256:abc123 572 + # 2. AppView fetches signatures from alice's PDS 573 + # 3. AppView returns OCI referrers index 574 + # 4. Cosign downloads signature artifact 575 + # 5. Cosign verifies with public key 576 + # 6. Success! 488 577 489 - # Output: 490 - # ✓ Signature verification succeeded 491 - # 492 - # Signed by: did:plc:alice123 493 - # Key ID: alice-signing-key 494 - # Signed at: 2025-10-20T12:34:56Z 495 - # Algorithm: ecdsa-p256-sha256 578 + # Or with public key inline: 579 + cosign verify atcr.io/alice/myapp:latest --key '-----BEGIN PUBLIC KEY----- 580 + MFkwEwYHKoZI... 581 + -----END PUBLIC KEY-----' 496 582 ``` 497 583 498 - ### Key Rotation 584 + **Fetching public keys from ATProto:** 585 + 586 + Public keys are stored in ATProto records and can be fetched via standard XRPC: 499 587 500 588 ```bash 501 - # Generate new key 502 - notation key generate --id alice-signing-key-2 --type ecdsa 589 + # Query for public keys 590 + curl "https://atcr.io/xrpc/com.atproto.repo.listRecords?\ 591 + repo=did:plc:alice123&\ 592 + collection=io.atcr.signing.key" 503 593 504 - # Publish new key 505 - notation-atproto key publish alice-signing-key-2 594 + # Extract public key and save as PEM 595 + # Then use in Cosign: 596 + cosign verify atcr.io/alice/myapp:latest --key alice.pub 597 + ``` 506 598 507 - # Re-sign images with new key 508 - notation sign atcr.io/alice/myapp:latest --key alice-signing-key-2 599 + ### Kubernetes Policy Example (OCI Referrers API) 509 600 510 - # Revoke old key 511 - notation-atproto key revoke alice-signing-key 512 - 513 - # Plugin updates io.atcr.signing.key record: 514 - # { ..., "revoked": true, "revokedAt": "2025-11-01T..." } 515 - 516 - # Old signatures still exist but verification will fail 517 - # (revoked key = untrusted) 601 + ```yaml 602 + # Sigstore Policy Controller 603 + apiVersion: policy.sigstore.dev/v1beta1 604 + kind: ClusterImagePolicy 605 + metadata: 606 + name: atcr-images-must-be-signed 607 + spec: 608 + images: 609 + - glob: "atcr.io/*/*" 610 + authorities: 611 + - key: 612 + # Public key from ATProto record 613 + data: | 614 + -----BEGIN PUBLIC KEY----- 615 + MFkwEwYHKoZI... 616 + -----END PUBLIC KEY----- 518 617 ``` 519 618 520 - ### Key Expiration 619 + **How it works:** 620 + 1. Pod tries to run `atcr.io/alice/myapp:latest` 621 + 2. Policy Controller intercepts 622 + 3. Queries registry for OCI referrers (finds signature) 623 + 4. Verifies signature with public key 624 + 5. Allows pod if valid 521 625 522 - ```bash 523 - # Generate key with expiration 524 - notation key generate \ 525 - --id alice-signing-key \ 526 - --type ecdsa \ 527 - --expires 365d # 1 year 626 + ### Trust Policies 528 627 529 - # Publish with expiration 530 - notation-atproto key publish alice-signing-key 628 + Define what signatures are required for image execution: 531 629 532 - # PDS record: 533 - # { 534 - # "validFrom": "2025-10-20T12:00:00Z", 535 - # "expiresAt": "2026-10-20T12:00:00Z" 536 - # } 630 + ```yaml 631 + # ~/.atcr/trust-policy.yaml 632 + policies: 633 + - name: production-images 634 + scope: "atcr.io/alice/prod-*" 635 + require: 636 + - signature: true 637 + - keyIds: ["ci-key-1", "alice-release-key"] 638 + action: enforce # block, audit, or allow 537 639 538 - # After expiration, verification fails: 539 - notation verify atcr.io/alice/myapp:latest 540 - # ✗ Signature verification failed 541 - # Signing key expired on 2026-10-20T12:00:00Z 640 + - name: dev-images 641 + scope: "atcr.io/alice/dev-*" 642 + require: 643 + - signature: false 644 + action: audit 542 645 ``` 543 646 647 + **Integration points:** 648 + - Kubernetes admission controller 649 + - Docker Content Trust equivalent 650 + - CI/CD pipeline gates 651 + 544 652 ## Security Considerations 545 653 546 - ### Key Storage 654 + ### Key Storage Security 547 655 548 - **Private keys must be protected:** 549 - - File permissions: `0600` (owner read/write only) 550 - - Use OS keychain when possible (macOS Keychain, Windows Credential Manager) 551 - - Consider hardware tokens (YubiKey, TPM) for production 552 - - Never commit private keys to git 656 + **OS keychain benefits:** 657 + - ✅ Encrypted storage 658 + - ✅ Access control (requires user password/biometric) 659 + - ✅ Auditing (macOS logs keychain access) 660 + - ✅ Hardware-backed on modern systems (Secure Enclave, TPM) 553 661 554 - **Public keys are public:** 555 - - Stored in user's PDS (publicly readable) 556 - - Anyone can verify signatures 557 - - Revocation is public and immediate 662 + **Best practices:** 663 + - Generate keys on device (never transmitted) 664 + - Use hardware-backed storage when available 665 + - Require user approval for key access (biometric/password) 666 + - Rotate keys periodically (e.g., annually) 558 667 559 668 ### Trust Model 560 669 561 670 **What signatures prove:** 562 - - ✅ Image manifest hasn't been tampered with since signing 563 - - ✅ Signer had access to private key at signing time 564 - - ✅ Signer's DID matches image owner (alice signed alice/myapp) 671 + - ✅ User had access to private key at signing time 672 + - ✅ Manifest digest matches what was signed 673 + - ✅ Signature created by specific key ID 674 + - ✅ Timestamp of signature creation 565 675 566 676 **What signatures don't prove:** 567 677 - ❌ Image is free of vulnerabilities 568 678 - ❌ Image contents are safe to run 569 - - ❌ Signer's identity is verified (depends on DID trust) 679 + - ❌ User's identity is verified (depends on DID trust) 680 + - ❌ Private key wasn't compromised 570 681 571 - **Trust anchors:** 572 - - Trust PDS to correctly serve signing key records 573 - - Trust DID resolution (PLC directory, did:web DNS) 574 - - Trust signature algorithms (ECDSA, Ed25519, RSA) 575 - - Trust user to protect their private keys 682 + **Trust dependencies:** 683 + - User protects their private key 684 + - OS keychain security 685 + - DID resolution accuracy (PLC directory, did:web) 686 + - PDS serves correct public key records 687 + - Signature algorithms remain secure 576 688 577 - ### Key Compromise 689 + ### Multi-Device Support 578 690 579 - If a private signing key is compromised: 691 + **Challenge:** User has multiple devices (laptop, desktop, CI/CD) 580 692 581 - ```bash 582 - # 1. Immediately revoke the key 583 - notation-atproto key revoke alice-signing-key --reason "Key compromised" 693 + **Options:** 584 694 585 - # 2. Generate new key 586 - notation key generate --id alice-signing-key-new --type ecdsa 695 + 1. **Separate keys per device:** 696 + ```json 697 + { 698 + "keyId": "alice-macbook-pro", 699 + "deviceId": "macbook-pro" 700 + }, 701 + { 702 + "keyId": "alice-desktop", 703 + "deviceId": "desktop" 704 + } 705 + ``` 706 + - Pros: Best security (key compromise limited to one device) 707 + - Cons: Need to trust signatures from any device 587 708 588 - # 3. Publish new key 589 - notation-atproto key publish alice-signing-key-new 709 + 2. **Shared key via secure sync:** 710 + - Export key from primary device 711 + - Import to secondary devices 712 + - Stored in each device's keychain 713 + - Pros: Single key ID to trust 714 + - Cons: More attack surface (key on multiple devices) 590 715 591 - # 4. Re-sign all images with new key 592 - for image in $(docker images --format "{{.Repository}}:{{.Tag}}"); do 593 - notation sign $image --key alice-signing-key-new 594 - done 716 + 3. **Primary + secondary model:** 717 + - Primary key on main device 718 + - Secondary keys on other devices 719 + - Trust policy requires primary key signature 720 + - Pros: Flexible + secure 721 + - Cons: More complex setup 595 722 596 - # 5. Alert users to only trust new key 597 - # (Old signatures will fail verification due to revocation) 598 - ``` 723 + **Recommendation:** Separate keys per device (Option 1) for security, with trust policy accepting any of user's keys. 599 724 600 - **Revocation is immediate:** 601 - - PDS record updated with `"revoked": true` 602 - - All verification attempts fail instantly 603 - - No need to update certificate revocation lists (CRLs) 604 - - ATProto record queries are always fresh 725 + ### Key Compromise Response 605 726 606 - ### Multiple Signatures 727 + If a device is lost or private key is compromised: 607 728 608 - Images can have multiple signatures: 729 + 1. **Revoke the key** via AppView web UI or XRPC API 730 + - Updates `io.atcr.signing.key` record: `"revoked": true` 731 + - Revocation is atomic and immediate 609 732 610 - ```bash 611 - # Alice signs with her key 612 - notation sign atcr.io/alice/myapp:latest --key alice-signing-key 733 + 2. **Generate new key** on new/existing device 734 + - Automatic on next `docker login` from secure device 735 + - Credential helper generates new key pair 613 736 614 - # CI/CD system signs with separate key 615 - notation sign atcr.io/alice/myapp:latest --key ci-signing-key 737 + 3. **Old signatures still exist but fail verification** 738 + - Revoked key = untrusted 739 + - No certificate revocation list (CRL) delays 740 + - Globally visible within seconds 616 741 617 - # Both signatures stored in alice's PDS 618 - # Verification requires both (configurable in trust policy) 619 - ``` 742 + ### CI/CD Signing 620 743 621 - **Trust policy:** 622 - ```json 623 - { 624 - "trustPolicies": [{ 625 - "name": "require-dual-signature", 626 - "registryScopes": ["atcr.io/alice/*"], 627 - "signatureVerification": { 628 - "level": "strict", 629 - "verifyTimestamp": true, 630 - "override": { 631 - "all": ["alice-signing-key", "ci-signing-key"] 632 - } 633 - } 634 - }] 635 - } 744 + For automated builds, use standard Cosign in your CI pipeline: 745 + 746 + ```yaml 747 + # .github/workflows/build.yml 748 + steps: 749 + - name: Push image 750 + run: docker push atcr.io/alice/myapp:latest 751 + 752 + - name: Sign with Cosign 753 + run: cosign sign atcr.io/alice/myapp:latest --key ${{ secrets.COSIGN_KEY }} 636 754 ``` 637 755 756 + **Key management:** 757 + - Generate Cosign key pair: `cosign generate-key-pair` 758 + - Store private key in CI secrets (GitHub Actions, GitLab CI, etc.) 759 + - Publish public key to PDS via XRPC or AppView web UI 760 + - Cosign stores signature via registry's OCI API 761 + - AppView automatically stores in ATProto 762 + 763 + **Or use automatic signing:** 764 + - Configure credential helper in CI environment 765 + - Signatures happen automatically on push 766 + - No explicit signing step needed 767 + 638 768 ## Implementation Roadmap 639 769 640 - ### Phase 1: Core Plugin (4-6 weeks) 770 + ### Phase 1: Core Signing (2-3 weeks) 641 771 642 - **Week 1-2: Trust store plugin** 643 - - Implement DID resolution 644 - - Query `io.atcr.signing.key` records 645 - - Parse and validate public keys 646 - - Handle revocation and expiration 772 + **Week 1: Credential helper key management** 773 + - Generate ECDSA key pair on first run 774 + - Store private key in OS keychain 775 + - Create `io.atcr.signing.key` record in PDS 776 + - Handle key rotation 777 + 778 + **Week 2: Signing integration** 779 + - Sign manifest digest after authentication 780 + - Send signature to AppView via XRPC 781 + - AppView stores in user's PDS or hold's PDS 782 + - Error handling and retries 783 + 784 + **Week 3: OCI Referrers API** 785 + - Implement `GET /v2/{owner}/{repo}/referrers/{digest}` in AppView 786 + - Query ATProto for signatures 787 + - Transform to OCI Index format 788 + - Return Cosign-compatible artifacts 789 + - Test with `cosign verify` 647 790 648 - **Week 3-4: Signature store plugin** 649 - - OAuth integration for PDS writes 650 - - Create `io.atcr.signature` records 651 - - Query signatures for verification 652 - - Handle record key generation 791 + ### Phase 2: Enhanced Features (2-3 weeks) 653 792 654 - **Week 5-6: Integration testing** 655 - - End-to-end sign/verify workflows 656 - - Key rotation scenarios 793 + **Key management (credential helper):** 794 + - Key rotation support 657 795 - Revocation handling 658 - - Multi-signature support 796 + - Device identification 797 + - Key expiration 798 + 799 + **Signature storage:** 800 + - Handle manual Cosign signing (via OCI API) 801 + - Store signatures from both automatic and manual flows 802 + - Signature deduplication 803 + - Signature audit logs 804 + 805 + **AppView endpoints:** 806 + - XRPC endpoints for key/signature queries 807 + - Web UI for viewing keys and signatures 808 + - Key revocation via web interface 809 + 810 + ### Phase 3: Kubernetes Integration (2-3 weeks) 811 + 812 + **Admission controller setup:** 813 + - Documentation for Sigstore Policy Controller 814 + - Example policies for ATCR images 815 + - Public key management (fetch from ATProto) 816 + - Integration testing with real clusters 817 + 818 + **Advanced features:** 819 + - Signature caching in AppView (reduce PDS queries) 820 + - Multi-signature support (require N signatures) 821 + - Timestamp verification 822 + - Signature expiration policies 823 + 824 + ### Phase 4: UI Integration (1-2 weeks) 825 + 826 + **AppView web UI:** 827 + - Show signature status on repository pages 828 + - List signing keys for users 829 + - Revoke keys via web interface 830 + - Signature verification badges 831 + 832 + ## Comparison: Automatic vs Manual Signing 833 + 834 + | Feature | Automatic (Credential Helper) | Manual (Standard Cosign) | 835 + |---------|-------------------------------|--------------------------| 836 + | **User action** | Zero - happens on push | `cosign sign` after push | 837 + | **Key management** | Automatic generation/storage | User manages keys | 838 + | **Consistency** | Every image signed | Easy to forget | 839 + | **Setup** | Works with credential helper | Install Cosign, generate keys | 840 + | **CI/CD** | Automatic if cred helper configured | Explicit signing step | 841 + | **Flexibility** | Opinionated defaults | Full control over workflow | 842 + | **Use case** | Most users, simple workflows | Advanced users, custom workflows | 659 843 660 - ### Phase 2: Tooling (2-3 weeks) 844 + **Recommendation:** 845 + - **Start with automatic**: Best UX, works for most users 846 + - **Use manual** for: CI/CD pipelines, hardware tokens, custom signing workflows 661 847 662 - **CLI commands:** 848 + ## Complete Workflow Summary 849 + 850 + ### Option 1: Automatic Signing (Recommended) 851 + 663 852 ```bash 664 - notation-atproto key generate 665 - notation-atproto key publish 666 - notation-atproto key list 667 - notation-atproto key revoke 668 - notation-atproto signature list <image> 669 - notation-atproto signature inspect <image> 853 + # Setup (one time) 854 + docker login atcr.io 855 + # → Credential helper generates ECDSA key pair 856 + # → Private key in OS keychain 857 + # → Public key published to PDS 858 + 859 + # Push (automatic signing) 860 + docker push atcr.io/alice/myapp:latest 861 + # → Image pushed and signed automatically 862 + # → No extra commands! 863 + 864 + # Verify (standard Cosign) 865 + cosign verify atcr.io/alice/myapp:latest --key alice.pub 866 + # → Cosign queries OCI Referrers API 867 + # → AppView returns ATProto signatures as OCI artifacts 868 + # → Verification succeeds ✓ 670 869 ``` 671 870 672 - **Helper utilities:** 673 - - Bulk re-signing for key rotation 674 - - Signature audit logs 675 - - Trust policy generators 676 - - Key lifecycle management 871 + ### Option 2: Manual Signing (DIY) 677 872 678 - ### Phase 3: AppView Integration (2-3 weeks) 873 + ```bash 874 + # Push image 875 + docker push atcr.io/alice/myapp:latest 679 876 680 - **Web UI features:** 681 - - Display signature status on repository pages 682 - - Show signing keys for users 683 - - Signature verification badges 684 - - Key management interface 877 + # Sign with Cosign 878 + cosign sign atcr.io/alice/myapp:latest --key cosign.key 879 + # → Cosign stores via OCI API 880 + # → AppView stores in ATProto 685 881 686 - **API endpoints:** 687 - - `GET /v2/alice/myapp/signatures` - List signatures for image 688 - - `GET /v2/alice/keys` - List user's signing keys 689 - - `POST /v2/alice/keys/revoke` - Revoke key via web UI 882 + # Verify (same as automatic) 883 + cosign verify atcr.io/alice/myapp:latest --key cosign.pub 884 + ``` 690 885 691 - ### Phase 4: Advanced Features (ongoing) 886 + ### Kubernetes (Standard Admission Controller) 692 887 693 - **Hardware token support:** 694 - - YubiKey integration 695 - - TPM-backed keys 696 - - Hardware-backed keystores 888 + ```yaml 889 + # Sigstore Policy Controller (standard) 890 + apiVersion: policy.sigstore.dev/v1beta1 891 + kind: ClusterImagePolicy 892 + metadata: 893 + name: atcr-signed-only 894 + spec: 895 + images: 896 + - glob: "atcr.io/*/*" 897 + authorities: 898 + - key: 899 + data: | 900 + -----BEGIN PUBLIC KEY----- 901 + [Alice's public key from ATProto] 902 + -----END PUBLIC KEY----- 903 + ``` 697 904 698 - **Timestamp verification:** 699 - - Trusted timestamp authorities 700 - - Prove signature was created at specific time 701 - - Long-term signature validity 905 + **How admission control works:** 906 + 1. Pod tries to start with `atcr.io/alice/myapp:latest` 907 + 2. Policy Controller intercepts 908 + 3. Calls `GET /v2/alice/myapp/referrers/sha256:abc123` 909 + 4. AppView returns signatures from ATProto 910 + 5. Policy Controller verifies with public key 911 + 6. Pod allowed to start ✓ 702 912 703 - **SBOM signing:** 704 - - Sign SBOMs with same keys 705 - - Link SBOM signatures to image signatures 706 - - Unified verification workflow 913 + ### Key Design Points 707 914 708 - ## Comparison: Cosign vs Notary v2 for ATCR 915 + **User experience:** 916 + - ✅ Two options: automatic (credential helper) or manual (standard Cosign) 917 + - ✅ Standard verification tools work (Cosign, Notary, Kubernetes) 918 + - ✅ No custom ATCR-specific signing commands 919 + - ✅ User-controlled keys (OS keychain or self-managed) 709 920 710 - | Feature | Cosign | Notary v2 | Winner | 711 - |---------|--------|-----------|--------| 712 - | **ATProto integration** | Requires OIDC bridge | Plugin system | ✅ Notary | 713 - | **Key format flexibility** | Limited | Extensible | ✅ Notary | 714 - | **Custom storage** | OCI only | Pluggable | ✅ Notary | 715 - | **Infrastructure needs** | Fulcio + Rekor | None | ✅ Notary | 716 - | **Keyless signing** | Yes (complex) | No | ⚠️ Cosign* | 717 - | **Ecosystem maturity** | High | Medium | ⚠️ Cosign* | 718 - | **CLI simplicity** | Very simple | Simple | ⚠️ Cosign* | 719 - | **Plugin development** | N/A | Required | ⚠️ Mixed | 921 + **Architecture:** 922 + - **Signing**: Client-side only (credential helper or Cosign) 923 + - **Storage**: ATProto (user's PDS or hold's PDS via `io.atcr.signature`) 924 + - **Verification**: Standard tools via OCI Referrers API bridge 925 + - **Bridge**: AppView transforms ATProto → OCI format on-demand 720 926 721 - *Cosign advantages don't outweigh ATProto incompatibilities 722 - 723 - **Recommendation: Notary v2 with ATProto plugin** 927 + **Why this works:** 928 + - ✅ No server-side signing needed (impossible with ATProto constraints) 929 + - ✅ Signatures discoverable via ATProto 930 + - ✅ No duplicate storage (single source of truth) 931 + - ✅ Standard OCI compliance for verification 724 932 725 933 ## References 726 934 935 + ### Signing & Verification 936 + - [Sigstore Cosign](https://github.com/sigstore/cosign) 727 937 - [Notary v2 Specification](https://notaryproject.dev/) 728 - - [Notation CLI](https://github.com/notaryproject/notation) 729 - - [Notary Plugin Specification](https://notaryproject.dev/docs/user-guides/how-to/plugin-management/) 730 - - [Sigstore Cosign](https://github.com/sigstore/cosign) (for comparison) 731 - - [ATProto Specification](https://atproto.com/) 938 + - [Cosign Signature Specification](https://github.com/sigstore/cosign/blob/main/specs/SIGNATURE_SPEC.md) 939 + 940 + ### OCI & Registry 941 + - [OCI Distribution Specification](https://github.com/opencontainers/distribution-spec) 942 + - [OCI Referrers API](https://github.com/oras-project/artifacts-spec/blob/main/manifest-referrers-api.md) 732 943 - [OCI Artifacts](https://github.com/opencontainers/artifacts) 733 - - [RFC 7515 - JSON Web Signature](https://datatracker.ietf.org/doc/html/rfc7515) (signature formats) 944 + 945 + ### ATProto 946 + - [ATProto Specification](https://atproto.com/) 947 + - [ATProto Repository Specification](https://atproto.com/specs/repository) 948 + 949 + ### Key Management 950 + - [Docker Credential Helpers](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers) 951 + - [macOS Keychain Services](https://developer.apple.com/documentation/security/keychain_services) 952 + - [Windows Credential Manager](https://docs.microsoft.com/en-us/windows/security/identity-protection/credential-guard/) 953 + - [Linux Secret Service API](https://specifications.freedesktop.org/secret-service/) 954 + 955 + ### Kubernetes Integration 956 + - [Sigstore Policy Controller](https://docs.sigstore.dev/policy-controller/overview/) 957 + - [Ratify (Notary verification for Kubernetes)](https://ratify.dev/)
+43
pkg/appview/db/queries.go
··· 309 309 return repos, nil 310 310 } 311 311 312 + // GetRepositoryMetadata retrieves metadata for a repository from its most recent manifest 313 + func GetRepositoryMetadata(db *sql.DB, did string, repository string) (title, description, sourceURL, documentationURL, licenses, iconURL string, err error) { 314 + var titleNull, descriptionNull, sourceURLNull, documentationURLNull, licensesNull, iconURLNull sql.NullString 315 + 316 + err = db.QueryRow(` 317 + SELECT title, description, source_url, documentation_url, licenses, icon_url 318 + FROM manifests 319 + WHERE did = ? AND repository = ? 320 + ORDER BY created_at DESC 321 + LIMIT 1 322 + `, did, repository).Scan(&titleNull, &descriptionNull, &sourceURLNull, &documentationURLNull, &licensesNull, &iconURLNull) 323 + 324 + if err == sql.ErrNoRows { 325 + // No manifests found - return empty strings 326 + return "", "", "", "", "", "", nil 327 + } 328 + if err != nil { 329 + return "", "", "", "", "", "", err 330 + } 331 + 332 + // Convert NullString to string 333 + if titleNull.Valid { 334 + title = titleNull.String 335 + } 336 + if descriptionNull.Valid { 337 + description = descriptionNull.String 338 + } 339 + if sourceURLNull.Valid { 340 + sourceURL = sourceURLNull.String 341 + } 342 + if documentationURLNull.Valid { 343 + documentationURL = documentationURLNull.String 344 + } 345 + if licensesNull.Valid { 346 + licenses = licensesNull.String 347 + } 348 + if iconURLNull.Valid { 349 + iconURL = iconURLNull.String 350 + } 351 + 352 + return title, description, sourceURL, documentationURL, licenses, iconURL, nil 353 + } 354 + 312 355 // GetUserByDID retrieves a user by DID 313 356 func GetUserByDID(db *sql.DB, did string) (*User, error) { 314 357 var user User
+120
pkg/appview/db/queries_test.go
··· 1 + package db 2 + 3 + import ( 4 + "testing" 5 + "time" 6 + ) 7 + 8 + func TestGetRepositoryMetadata(t *testing.T) { 9 + // Create in-memory test database 10 + db, err := InitDB(":memory:") 11 + if err != nil { 12 + t.Fatalf("Failed to init database: %v", err) 13 + } 14 + defer db.Close() 15 + 16 + // Insert test user 17 + testUser := &User{ 18 + DID: "did:plc:test123", 19 + Handle: "testuser.bsky.social", 20 + PDSEndpoint: "https://test.pds.example.com", 21 + Avatar: "", 22 + LastSeen: time.Now(), 23 + } 24 + if err := UpsertUser(db, testUser); err != nil { 25 + t.Fatalf("Failed to insert user: %v", err) 26 + } 27 + 28 + // Test 1: No manifests - should return empty strings 29 + title, description, sourceURL, documentationURL, licenses, iconURL, err := GetRepositoryMetadata(db, testUser.DID, "nonexistent") 30 + if err != nil { 31 + t.Fatalf("Expected no error for nonexistent repo, got: %v", err) 32 + } 33 + if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" { 34 + t.Error("Expected all empty strings for nonexistent repository") 35 + } 36 + 37 + // Test 2: Insert manifest with metadata 38 + _, err = db.Exec(` 39 + INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at, 40 + title, description, source_url, documentation_url, licenses, icon_url) 41 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 42 + `, testUser.DID, "myapp", "sha256:abc123", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json", 43 + time.Now().Add(-2*time.Hour), 44 + "My App", "A cool application", "https://github.com/user/myapp", "https://docs.example.com", "MIT", "https://example.com/icon.png") 45 + if err != nil { 46 + t.Fatalf("Failed to insert manifest: %v", err) 47 + } 48 + 49 + // Test 3: Retrieve metadata 50 + title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp") 51 + if err != nil { 52 + t.Fatalf("Failed to get repository metadata: %v", err) 53 + } 54 + 55 + if title != "My App" { 56 + t.Errorf("Expected title 'My App', got '%s'", title) 57 + } 58 + if description != "A cool application" { 59 + t.Errorf("Expected description 'A cool application', got '%s'", description) 60 + } 61 + if sourceURL != "https://github.com/user/myapp" { 62 + t.Errorf("Expected sourceURL 'https://github.com/user/myapp', got '%s'", sourceURL) 63 + } 64 + if documentationURL != "https://docs.example.com" { 65 + t.Errorf("Expected documentationURL 'https://docs.example.com', got '%s'", documentationURL) 66 + } 67 + if licenses != "MIT" { 68 + t.Errorf("Expected licenses 'MIT', got '%s'", licenses) 69 + } 70 + if iconURL != "https://example.com/icon.png" { 71 + t.Errorf("Expected iconURL 'https://example.com/icon.png', got '%s'", iconURL) 72 + } 73 + 74 + // Test 4: Insert newer manifest with different metadata 75 + _, err = db.Exec(` 76 + INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at, 77 + title, description, source_url, documentation_url, licenses, icon_url) 78 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 79 + `, testUser.DID, "myapp", "sha256:def456", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json", 80 + time.Now(), // Most recent 81 + "My App v2", "An even cooler application", "https://github.com/user/myapp-v2", "https://v2.docs.example.com", "Apache-2.0", "https://example.com/icon-v2.png") 82 + if err != nil { 83 + t.Fatalf("Failed to insert newer manifest: %v", err) 84 + } 85 + 86 + // Test 5: Should return metadata from most recent manifest 87 + title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp") 88 + if err != nil { 89 + t.Fatalf("Failed to get repository metadata: %v", err) 90 + } 91 + 92 + if title != "My App v2" { 93 + t.Errorf("Expected title from newest manifest 'My App v2', got '%s'", title) 94 + } 95 + if description != "An even cooler application" { 96 + t.Errorf("Expected description from newest manifest, got '%s'", description) 97 + } 98 + if licenses != "Apache-2.0" { 99 + t.Errorf("Expected licenses 'Apache-2.0', got '%s'", licenses) 100 + } 101 + 102 + // Test 6: Manifest with NULL metadata fields 103 + _, err = db.Exec(` 104 + INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at) 105 + VALUES (?, ?, ?, ?, ?, ?, ?) 106 + `, testUser.DID, "minimal-app", "sha256:minimal", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json", time.Now()) 107 + if err != nil { 108 + t.Fatalf("Failed to insert minimal manifest: %v", err) 109 + } 110 + 111 + // Test 7: Should handle NULL fields gracefully 112 + title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "minimal-app") 113 + if err != nil { 114 + t.Fatalf("Failed to get repository metadata for minimal app: %v", err) 115 + } 116 + 117 + if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" { 118 + t.Error("Expected all empty strings for manifest with NULL metadata fields") 119 + } 120 + }
+14
pkg/appview/handlers/repository.go
··· 134 134 ManifestCount: len(manifests), 135 135 } 136 136 137 + // Fetch repository metadata from most recent manifest 138 + title, description, sourceURL, documentationURL, licenses, iconURL, err := db.GetRepositoryMetadata(h.DB, owner.DID, repository) 139 + if err != nil { 140 + log.Printf("Failed to fetch repository metadata: %v", err) 141 + // Continue without metadata on error 142 + } else { 143 + repo.Title = title 144 + repo.Description = description 145 + repo.SourceURL = sourceURL 146 + repo.DocumentationURL = documentationURL 147 + repo.Licenses = licenses 148 + repo.IconURL = iconURL 149 + } 150 + 137 151 // Fetch star count 138 152 stats, err := db.GetRepositoryStats(h.DB, owner.DID, repository) 139 153 if err != nil {
+17
pkg/appview/holdhealth/checker_test.go
··· 251 251 t.Errorf("Expected unreachable=1, got %v", stats["unreachable"]) 252 252 } 253 253 } 254 + 255 + func TestNewWorkerWithStartupDelay(t *testing.T) { 256 + checker := NewChecker(15 * time.Minute) 257 + 258 + // Test NewWorker (no delay) 259 + worker := NewWorker(checker, nil, 5*time.Minute) 260 + if worker.startupDelay != 0 { 261 + t.Errorf("Expected startupDelay=0 for NewWorker, got %v", worker.startupDelay) 262 + } 263 + 264 + // Test NewWorkerWithStartupDelay 265 + startupDelay := 5 * time.Second 266 + workerWithDelay := NewWorkerWithStartupDelay(checker, nil, 5*time.Minute, startupDelay) 267 + if workerWithDelay.startupDelay != startupDelay { 268 + t.Errorf("Expected startupDelay=%v, got %v", startupDelay, workerWithDelay.startupDelay) 269 + } 270 + }
+27 -1
pkg/appview/holdhealth/worker.go
··· 22 22 cleanupTicker *time.Ticker 23 23 stopChan chan struct{} 24 24 wg sync.WaitGroup 25 + startupDelay time.Duration 25 26 } 26 27 27 28 // NewWorker creates a new background worker ··· 32 33 refreshTicker: time.NewTicker(refreshInterval), 33 34 cleanupTicker: time.NewTicker(30 * time.Minute), // Cleanup every 30 minutes 34 35 stopChan: make(chan struct{}), 36 + startupDelay: 0, // No delay by default for backward compatibility 37 + } 38 + } 39 + 40 + // NewWorkerWithStartupDelay creates a new background worker with a startup delay 41 + func NewWorkerWithStartupDelay(checker *Checker, db DBQuerier, refreshInterval, startupDelay time.Duration) *Worker { 42 + return &Worker{ 43 + checker: checker, 44 + db: db, 45 + refreshTicker: time.NewTicker(refreshInterval), 46 + cleanupTicker: time.NewTicker(30 * time.Minute), // Cleanup every 30 minutes 47 + stopChan: make(chan struct{}), 48 + startupDelay: startupDelay, 35 49 } 36 50 } 37 51 ··· 43 57 44 58 log.Println("Hold health worker: Starting background health checks") 45 59 46 - // Perform initial check immediately 60 + // Wait for services to be ready (Docker startup race condition) 61 + if w.startupDelay > 0 { 62 + log.Printf("Hold health worker: Waiting %s for services to be ready...", w.startupDelay) 63 + select { 64 + case <-time.After(w.startupDelay): 65 + // Continue with initial check 66 + case <-ctx.Done(): 67 + log.Println("Hold health worker: Context cancelled during startup delay") 68 + return 69 + } 70 + } 71 + 72 + // Perform initial check 47 73 w.refreshAllHolds(ctx) 48 74 49 75 for {