···7272# ATCR_LOG_FORMATTER=text
73737474# ==============================================================================
7575+# Hold Health Check Configuration
7676+# ==============================================================================
7777+7878+# How often to check health of hold endpoints in the background (default: 15m)
7979+# Queries database for unique hold endpoints and checks if they're reachable
8080+# Examples: 5m, 15m, 30m, 1h
8181+# ATCR_HEALTH_CHECK_INTERVAL=15m
8282+8383+# How long to cache health check results (default: 15m)
8484+# Cached results avoid redundant health checks on page renders
8585+# Should be >= ATCR_HEALTH_CHECK_INTERVAL for efficiency
8686+# Examples: 15m, 30m, 1h
8787+# ATCR_HEALTH_CACHE_TTL=15m
8888+8989+# ==============================================================================
7590# Jetstream Configuration (ATProto event streaming)
7691# ==============================================================================
7792
+24-4
cmd/appview/serve.go
···75757676 // Initialize hold health checker
7777 fmt.Println("Initializing hold health checker...")
7878- cacheTTL := 15 * time.Minute // Cache TTL from user requirements
7878+7979+ // Parse health check cache TTL from environment (default: 15m)
8080+ cacheTTL := 15 * time.Minute
8181+ if cacheTTLStr := os.Getenv("ATCR_HEALTH_CACHE_TTL"); cacheTTLStr != "" {
8282+ if parsed, err := time.ParseDuration(cacheTTLStr); err == nil {
8383+ cacheTTL = parsed
8484+ } else {
8585+ fmt.Printf("Warning: Invalid ATCR_HEALTH_CACHE_TTL '%s', using default 15m\n", cacheTTLStr)
8686+ }
8787+ }
8888+7989 healthChecker := holdhealth.NewChecker(cacheTTL)
80908191 // Start background health check worker
8282- refreshInterval := 5 * time.Minute // Refresh every 5 minutes
9292+ // Parse refresh interval from environment (default: 15m)
9393+ refreshInterval := 15 * time.Minute
9494+ if refreshIntervalStr := os.Getenv("ATCR_HEALTH_CHECK_INTERVAL"); refreshIntervalStr != "" {
9595+ if parsed, err := time.ParseDuration(refreshIntervalStr); err == nil {
9696+ refreshInterval = parsed
9797+ } else {
9898+ fmt.Printf("Warning: Invalid ATCR_HEALTH_CHECK_INTERVAL '%s', using default 15m\n", refreshIntervalStr)
9999+ }
100100+ }
101101+102102+ startupDelay := 5 * time.Second // Wait for hold services to start (Docker compose)
83103 dbAdapter := holdhealth.NewDBAdapter(uiDatabase)
8484- healthWorker := holdhealth.NewWorker(healthChecker, dbAdapter, refreshInterval)
104104+ healthWorker := holdhealth.NewWorkerWithStartupDelay(healthChecker, dbAdapter, refreshInterval, startupDelay)
8510586106 // Create context for worker lifecycle management
87107 workerCtx, workerCancel := context.WithCancel(context.Background())
88108 defer workerCancel() // Ensure context is cancelled on all exit paths
89109 healthWorker.Start(workerCtx)
9090- fmt.Println("Hold health worker started (5min refresh interval, 15min cache TTL)")
110110+ fmt.Printf("Hold health worker started (5s startup delay, %s refresh interval, %s cache TTL)\n", refreshInterval, cacheTTL)
9111192112 // Initialize OAuth components
93113 fmt.Println("Initializing OAuth components...")
+734-510
docs/IMAGE_SIGNING.md
···11# Image Signing with ATProto
2233-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.
33+ATCR supports cryptographic signing of container images to ensure authenticity and integrity. Users have two options:
4455-## Background: Why Not Cosign?
55+1. **Automatic signing (recommended)**: Credential helper signs images automatically on every push
66+2. **Manual signing**: Use standard Cosign tools yourself
6777-[Sigstore Cosign](https://github.com/sigstore/cosign) is the most popular OCI image signing tool, but has several incompatibilities with ATProto:
88+Both approaches use the OCI Referrers API bridge for verification with standard tools (Cosign, Notary, Kubernetes admission controllers).
8999-### 1. Key Format Mismatch
1010+## Design Constraints
10111111-**ATProto PDS keys:**
1212-- Format: secp256k1 (K256) for signing
1313-- Purpose: ATProto record signatures, DID authentication
1414-- Access: Private keys never leave the PDS server
1515-- Standard: ATProto specification
1212+### Why Server-Side Signing Doesn't Work
16131717-**Cosign expected keys:**
1818-- Format: ECDSA P-256, RSA, or Ed25519
1919-- Purpose: Image signing (not ATProto records)
2020-- Access: User-controlled private keys
2121-- Standard: Sigstore/PKIX
1414+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:
22152323-**Problem:** Can't use PDS keys directly for Cosign signing - wrong curve, wrong access model, wrong security boundary.
1616+**The problem: Signing "on behalf of" isn't real signing**
24172525-### 2. No Direct PDS Key Access
1818+```
1919+❌ AppView signs image → Proves "AppView vouches for this"
2020+❌ Hold signs image → Proves "Hold vouches for this"
2121+❌ PDS signs image → Proves "PDS vouches for this"
2222+✅ Alice signs image → Proves "Alice created/approved this"
2323+```
26242727-**Security model:**
2828-- PDS private keys are server-side secrets
2929-- Never exposed to clients (even authenticated users)
3030-- Used only by PDS for ATProto operations
3131-- Exposing them would compromise entire account security
2525+**Why GitHub can do it:**
2626+- GitHub Actions runs with your GitHub identity
2727+- OIDC token proves "this workflow runs as alice on GitHub"
2828+- Fulcio certificate authority issues cert based on that proof
2929+- Still "alice" signing, just via GitHub's infrastructure
32303333-**Cosign requirement:**
3434-- Needs access to private key for signing operations
3535-- Expects user-controlled keys or KMS integration
3131+**Why ATCR can't replicate this:**
3232+- ATProto doesn't have OIDC/Fulcio equivalent
3333+- AppView can't sign "as alice" - only alice can
3434+- No secure server-side storage for user private keys
3535+ - ATProto doesn't have encrypted record storage yet
3636+ - Storing keys in AppView database = AppView controls keys, not alice
3737+- Hold's PDS has its own private key, but signing with it proves hold ownership, not user ownership
36383737-**Problem:** Can't sign images client-side with PDS keys without fundamentally breaking ATProto security model.
3939+**Conclusion:** Signing must happen **client-side with user-controlled keys**.
38403939-### 3. Keyless Signing Complexity
4141+### Why ATProto Record Signatures Aren't Sufficient
40424141-Cosign supports "keyless" signing via OIDC + Fulcio CA:
4343+ATProto already signs all records stored in PDSs. When a manifest is stored as an `io.atcr.manifest` record, it includes:
42444343-**What it requires:**
4444-- OIDC identity provider (Google, GitHub, etc.)
4545-- Fulcio certificate authority (issues short-lived certs)
4646-- Rekor transparency log (immutable signature log)
4747-- All infrastructure managed by Sigstore
4545+```json
4646+{
4747+ "uri": "at://did:plc:alice123/io.atcr.manifest/abc123",
4848+ "cid": "bafyrei...",
4949+ "value": { /* manifest data */ },
5050+ "sig": "..." // ← PDS signature over record
5151+}
5252+```
48534949-**ATProto adaptation would need:**
5050-- **OIDC bridge**: Make ATProto DIDs look like OIDC identities
5151- - Map `did:plc:alice123` → OIDC claims
5252- - PDS as OIDC provider? (not in spec)
5353- - Requires custom OIDC server wrapping ATProto auth
5454-- **Fulcio adaptation**: Issue certs based on ATProto identities
5555- - Deploy and manage CA infrastructure
5656- - Handle DID resolution in cert issuance
5757- - Trust anchor distribution
5858-- **Rekor instance**: Public transparency log for signatures
5959- - High availability requirements
6060- - Storage and indexing at scale
6161- - Replication and backup
5454+**What this proves:**
5555+- ✅ Alice's PDS created and signed this record
5656+- ✅ Record hasn't been tampered with since signing
5757+- ✅ CID correctly represents the record content
62586363-**Problem:** Too much infrastructure for ATCR to host and manage. Defeats the purpose of decentralized architecture.
5959+**What this doesn't prove:**
6060+- ❌ Alice personally approved this image
6161+- ❌ Alice's private key was involved (only PDS key)
64626565-### 4. Signature Storage
6363+**The gap:**
6464+- A compromised or malicious PDS could create fake manifest records and sign them validly
6565+- PDS operator could sign manifests without user's knowledge
6666+- No proof that the *user* (not just their PDS) approved the image
66676767-**Cosign storage:**
6868-- OCI registry artifacts (signatures as ORAS manifests)
6969-- Stored alongside images in registry
6868+**For true image signing, we need:**
6969+- User-controlled private keys (not PDS keys)
7070+- Client-side signing (where user has key access)
7171+- Separate signature records proving user approval
70727171-**ATCR ideal:**
7272-- Signatures in ATProto records (user's PDS)
7373-- Discoverable via ATProto queries
7474-- Integrated with ATProto's existing signature/verification model
7373+**Important nuance - PDS Trust Spectrum:**
75747676-**Problem:** Would need to patch Cosign or run dual storage (OCI + ATProto) which creates consistency issues.
7575+While ATProto records are always signed by the PDS, this doesn't provide user-level signing for image verification:
77767878-### Conclusion: Cosign Doesn't Fit
7777+1. **Self-hosted PDS with user-controlled keys:**
7878+ - User runs their own PDS and controls PDS rotation keys
7979+ - PDS signature ≈ user signature (trusted operator)
8080+ - Still doesn't work with standard tools (Cosign/Notary)
79818080-While Cosign is excellent for traditional registries, forcing it into ATProto would require:
8181-- Breaking ATProto security model (exposing PDS keys), OR
8282-- Building massive OIDC/Fulcio/Rekor infrastructure, OR
8383-- Running parallel storage systems with consistency problems
8282+2. **Shared/managed PDS (e.g., Bluesky):**
8383+ - PDS operated by third party (bsky.social)
8484+ - Auto-generated keys controlled by operator
8585+ - User doesn't have access to PDS rotation keys
8686+ - PDS signature ≠ user signature
84878585-**Better approach:** Use a more flexible signing framework designed for extensibility.
8888+**For ATCR:**
8989+- Credential helper signing works for all users (self-hosted or shared PDS)
9090+- Provides user-controlled keys separate from PDS keys
9191+- Works with standard verification tools via OCI Referrers API bridge
86928787-## Notary v2: Plugin-Based Architecture
9393+## Signing Options
88948989-[Notary v2](https://notaryproject.dev/) (also called "Notation" or "Notary Project") is a CNCF signature specification with a plugin architecture that fits ATProto better.
9595+### Option 1: Automatic Signing (Recommended)
90969191-### Why Notary v2?
9797+The credential helper automatically signs images on every push - no extra commands needed.
9898+9999+**How it works:**
100100+- Credential helper runs on every `docker push` for authentication
101101+- Extended to also sign the manifest digest with user's private key
102102+- Private key stored securely in OS keychain
103103+- Signature sent to AppView and stored in ATProto
104104+- Completely transparent to the user
921059393-**Flexible plugin system:**
9494-- **Trust store plugins**: Custom key resolution (e.g., from ATProto records)
9595-- **Signature plugins**: Custom signature storage (e.g., in PDS)
9696-- **Verification plugins**: Custom verification logic
9797-- Plugins written in any language, communicate via stdio
106106+### Architecture
981079999-**Multiple key types supported:**
100100-- ECDSA, RSA, Ed25519 out of box
101101-- Can support custom key types via plugins
102102-- Signature envelope format is extensible
108108+```
109109+┌─────────────────────────────────────────────────────┐
110110+│ docker push atcr.io/alice/myapp:latest │
111111+└────────────────────┬────────────────────────────────┘
112112+ ↓
113113+┌─────────────────────────────────────────────────────┐
114114+│ docker-credential-atcr (runs automatically) │
115115+│ │
116116+│ 1. Authenticate to AppView (OAuth) │
117117+│ 2. Get registry JWT │
118118+│ 3. Sign manifest digest with local private key ← NEW
119119+│ 4. Send signature to AppView ← NEW
120120+│ │
121121+│ Private key stored in OS keychain │
122122+│ (macOS Keychain, Windows Credential Manager, etc.) │
123123+└────────────────────┬────────────────────────────────┘
124124+ ↓
125125+┌─────────────────────────────────────────────────────┐
126126+│ AppView │
127127+│ │
128128+│ 1. Receives signature from credential helper │
129129+│ 2. Stores in user's PDS (io.atcr.signature) │
130130+│ │
131131+│ OR stores in hold's PDS for BYOS scenarios │
132132+└─────────────────────────────────────────────────────┘
133133+```
103134104104-**Designed for extensibility:**
105105-- Not tied to specific PKI (unlike Cosign/Sigstore)
106106-- Trust policies are configurable
107107-- Storage backend is pluggable
108108-- Works with custom identity systems
135135+**User experience:**
109136110110-**Standard CLI:**
111111-- `notation sign` / `notation verify` commands
112112-- Users don't need to learn new tools
113113-- Integration with Docker/containerd
137137+```bash
138138+# One-time setup
139139+docker login atcr.io
140140+# → Credential helper generates ECDSA key pair
141141+# → Private key stored in OS keychain
142142+# → Public key published to user's PDS
114143115115-### Notary v2 Architecture
144144+# Every push (automatic signing)
145145+docker push atcr.io/alice/myapp:latest
146146+# → Image pushed
147147+# → Automatically signed by credential helper
148148+# → No extra commands!
116149150150+# Verification (standard Cosign)
151151+cosign verify atcr.io/alice/myapp:latest --key alice.pub
117152```
118118-┌─────────────────────┐
119119-│ notation CLI │ User signs/verifies images
120120-└──────────┬──────────┘
121121- │
122122- ├─────────────────────────────────────┐
123123- │ │
124124-┌──────────▼─────────┐ ┌───────────▼──────────┐
125125-│ Signing Plugin │ │ Trust Store Plugin │
126126-│ │ │ │
127127-│ - Read private key │ │ - Resolve DID → PDS │
128128-│ - Generate sig │ │ - Fetch public keys │
129129-│ - Store in PDS │ │ - Verify trust │
130130-└────────────────────┘ └──────────────────────┘
131131- │ │
132132- ▼ ▼
133133-┌─────────────────────────────────────────────────────────┐
134134-│ User's PDS (ATProto) │
135135-│ │
136136-│ io.atcr.signing.key (public keys) │
137137-│ io.atcr.signature (signatures) │
138138-└─────────────────────────────────────────────────────────┘
139139-```
153153+154154+### Option 2: Manual Signing (DIY)
155155+156156+Use standard Cosign tools yourself if you prefer manual control.
157157+158158+**How it works:**
159159+- You manage your own signing keys
160160+- You run `cosign sign` manually after pushing
161161+- Signatures stored in ATProto via OCI Referrers API
162162+- Full control over signing workflow
140163141141-## Proposed Design: ATProto Signing
164164+**User experience:**
142165143143-### Key Management
166166+```bash
167167+# Push image
168168+docker push atcr.io/alice/myapp:latest
144169145145-**Separate signing keys from PDS keys:**
170170+# Sign manually with Cosign
171171+cosign sign atcr.io/alice/myapp:latest --key cosign.key
146172147147-1. **User generates signing key pair locally:**
148148- ```bash
149149- notation key generate --id alice-signing-key --type ecdsa
150150- # Or: --type ed25519, --type rsa
151151- ```
173173+# Cosign stores signature via registry's OCI API
174174+# AppView receives signature and stores in ATProto
152175153153-2. **Public key published to ATProto:**
154154- ```json
155155- {
156156- "$type": "io.atcr.signing.key",
157157- "keyId": "alice-signing-key",
158158- "keyType": "ecdsa-p256",
159159- "publicKey": "-----BEGIN PUBLIC KEY-----\nMFkw...",
160160- "validFrom": "2025-10-20T12:00:00Z",
161161- "expiresAt": "2026-10-20T12:00:00Z",
162162- "revoked": false,
163163- "createdAt": "2025-10-20T12:00:00Z"
164164- }
165165- ```
176176+# Verification (same as automatic)
177177+cosign verify atcr.io/alice/myapp:latest --key cosign.pub
178178+```
166179167167-3. **Private key stored locally:**
168168- - Docker credential store
169169- - OS keychain (macOS Keychain, Windows Credential Manager)
170170- - File with restrictive permissions
171171- - Hardware security module (future)
180180+**When to use:**
181181+- Need specific signing workflows (e.g., CI/CD pipelines)
182182+- Want to use hardware tokens (YubiKey)
183183+- Prefer manual control over automatic signing
184184+- Already using Cosign in your organization
172185173173-**Why separate keys?**
174174-- ✅ No need to access PDS private keys
175175-- ✅ Standard key formats (ECDSA, Ed25519, RSA)
176176-- ✅ User controls key lifecycle
177177-- ✅ Can use hardware tokens (YubiKey, etc.)
178178-- ✅ Security boundary separation (signing ≠ identity)
179179-- ✅ Key rotation without changing DID
186186+### Key Management
180187181181-### Signing Flow
188188+**Key generation (first run):**
189189+1. Credential helper checks for existing signing key in OS keychain
190190+2. If not found, generates new ECDSA P-256 key pair (or Ed25519)
191191+3. Stores private key in OS keychain with access control
192192+4. Derives public key for publishing
182193194194+**Public key publishing:**
195195+```json
196196+{
197197+ "$type": "io.atcr.signing.key",
198198+ "keyId": "credential-helper-default",
199199+ "keyType": "ecdsa-p256",
200200+ "publicKey": "-----BEGIN PUBLIC KEY-----\nMFkw...",
201201+ "validFrom": "2025-10-20T12:00:00Z",
202202+ "expiresAt": null,
203203+ "revoked": false,
204204+ "purpose": ["image-signing"],
205205+ "deviceId": "alice-macbook-pro",
206206+ "createdAt": "2025-10-20T12:00:00Z"
207207+}
183208```
184184-1. User: notation sign atcr.io/alice/myapp:latest --key alice-signing-key
185209186186-2. notation-atproto plugin:
187187- a. Resolve image → manifest digest
188188- b. Read private key from local keystore
189189- c. Generate signature over manifest digest
190190- d. Get OAuth token for alice's PDS
191191- e. Create signature record in alice's PDS
210210+**Record stored in:** User's PDS at `io.atcr.signing.key/credential-helper-default`
192211193193-3. Signature stored in alice's PDS:
212212+**Key storage locations:**
213213+- **macOS:** Keychain Access (secure enclave on modern Macs)
214214+- **Windows:** Credential Manager / Windows Data Protection API
215215+- **Linux:** Secret Service API (gnome-keyring, kwallet)
216216+- **Fallback:** Encrypted file with restrictive permissions (0600)
217217+218218+### Signing Flow
219219+220220+```
221221+1. docker push atcr.io/alice/myapp:latest
222222+ ↓
223223+2. Docker daemon calls credential helper:
224224+ docker-credential-atcr get atcr.io
225225+ ↓
226226+3. Credential helper flow:
227227+ a. Authenticate via OAuth (existing)
228228+ b. Receive registry JWT from AppView (existing)
229229+ c. Fetch manifest digest from registry (NEW)
230230+ d. Load private key from OS keychain (NEW)
231231+ e. Sign manifest digest (NEW)
232232+ f. Send signature to AppView via XRPC (NEW)
233233+ ↓
234234+4. AppView stores signature:
194235 {
195236 "$type": "io.atcr.signature",
196237 "repository": "alice/myapp",
197238 "digest": "sha256:abc123...",
198198- "signature": "MEUCIQDx...", // base64 signature bytes
199199- "keyId": "alice-signing-key",
239239+ "signature": "MEUCIQDx...",
240240+ "keyId": "credential-helper-default",
200241 "signatureAlgorithm": "ecdsa-p256-sha256",
201242 "signedAt": "2025-10-20T12:34:56Z"
202243 }
203203-204204-4. Record key: sha256 of (digest + keyId) for deduplication
244244+ ↓
245245+5. Return registry JWT to Docker
246246+ ↓
247247+6. Docker proceeds with push
205248```
206249207207-### Verification Flow
208208-209209-```
210210-1. User: notation verify atcr.io/alice/myapp:latest
250250+### Signature Storage
211251212212-2. notation-atproto plugin:
213213- a. Resolve "alice" → did:plc:alice123 → pds.alice.com
214214- b. Fetch manifest digest: sha256:abc123
215215- c. Query alice's PDS for signatures:
216216- GET /xrpc/com.atproto.repo.listRecords?
217217- repo=did:plc:alice123&
218218- collection=io.atcr.signature
219219- d. Filter records matching digest: sha256:abc123
220220- e. For each signature:
221221- - Fetch public key from io.atcr.signing.key record
222222- - Check key not revoked, not expired
223223- - Verify signature bytes over digest
224224- - Check trust policy (is this key trusted?)
252252+**Option 1: User's PDS (Default)**
253253+- Signature stored in alice's PDS
254254+- Collection: `io.atcr.signature`
255255+- Discoverable via alice's ATProto repo
256256+- User owns all signing metadata
225257226226-3. Trust policy evaluation:
227227- - Signature valid cryptographically? ✓
228228- - Key belongs to image owner (alice)? ✓
229229- - Key not revoked? ✓
230230- - Key not expired? ✓
231231- - Trust policy satisfied? ✓
258258+**Option 2: Hold's PDS (BYOS)**
259259+- Signature stored in hold's embedded PDS
260260+- Useful for shared holds with multiple users
261261+- Hold acts as signature repository
262262+- Parallel to SBOM storage model
232263233233-4. Output: Verification succeeded ✓
264264+**Decision logic:**
265265+```go
266266+// In AppView signature handler
267267+if manifest.HoldDid != "" && manifest.HoldDid != appview.DefaultHoldDid {
268268+ // BYOS scenario - store in hold's PDS
269269+ storeSignatureInHold(manifest.HoldDid, signature)
270270+} else {
271271+ // Default - store in user's PDS
272272+ storeSignatureInUserPDS(userDid, signature)
273273+}
234274```
235275236236-### Trust Policies
276276+## Signature Format
237277238238-Notary v2 uses trust policies to define what signatures are required:
278278+Signatures are stored in a simple format in ATProto and transformed to Cosign-compatible format when served via the OCI Referrers API:
239279280280+**ATProto storage format:**
240281```json
241282{
242242- "version": "1.0",
243243- "trustPolicies": [
244244- {
245245- "name": "atcr-images",
246246- "registryScopes": ["atcr.io/*/*"],
247247- "signatureVerification": {
248248- "level": "strict"
249249- },
250250- "trustStores": ["atproto:default"],
251251- "trustedIdentities": [
252252- "did:plc:*" // Trust any ATProto DID
253253- ]
254254- }
255255- ]
283283+ "$type": "io.atcr.signature",
284284+ "repository": "alice/myapp",
285285+ "digest": "sha256:abc123...",
286286+ "signature": "base64-encoded-signature-bytes",
287287+ "keyId": "credential-helper-default",
288288+ "signatureAlgorithm": "ecdsa-p256-sha256",
289289+ "signedAt": "2025-10-20T12:34:56Z",
290290+ "format": "simple"
256291}
257292```
258293259259-**Policy options:**
260260-- `level: strict` - Signature required, verification must pass
261261-- `level: permissive` - Signature optional, but verified if present
262262-- `level: audit` - Signature logged but doesn't block
263263-- `level: skip` - No verification
294294+**OCI Referrers format (served by AppView):**
295295+```json
296296+{
297297+ "schemaVersion": 2,
298298+ "mediaType": "application/vnd.oci.image.index.v1+json",
299299+ "manifests": [{
300300+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
301301+ "digest": "sha256:...",
302302+ "artifactType": "application/vnd.dev.cosign.simplesigning.v1+json",
303303+ "annotations": {
304304+ "dev.sigstore.cosign.signature": "MEUCIQDx...",
305305+ "io.atcr.keyId": "credential-helper-default",
306306+ "io.atcr.signedAt": "2025-10-20T12:34:56Z"
307307+ }
308308+ }]
309309+}
310310+```
264311265265-**Trust store resolution:**
266266-- `atproto:default` - Use ATProto plugin to resolve keys
267267-- Plugin queries user's PDS for `io.atcr.signing.key` records
268268-- Verifies key is owned by the image owner (DID match)
312312+This allows:
313313+- Simple storage in ATProto
314314+- Compatible with Cosign verification
315315+- No duplicate storage needed
269316270270-### ATProto Records
317317+## ATProto Records
271318272272-**io.atcr.signing.key** - Public signing keys
319319+### io.atcr.signing.key - Public Signing Keys
273320274321```json
275322{
276323 "$type": "io.atcr.signing.key",
277277- "keyId": "alice-signing-key",
324324+ "keyId": "credential-helper-default",
278325 "keyType": "ecdsa-p256",
279326 "publicKey": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZI...",
280327 "validFrom": "2025-10-20T12:00:00Z",
281328 "expiresAt": "2026-10-20T12:00:00Z",
282329 "revoked": false,
283330 "purpose": ["image-signing"],
331331+ "deviceId": "alice-macbook-pro",
332332+ "comment": "Generated by docker-credential-atcr",
284333 "createdAt": "2025-10-20T12:00:00Z"
285334}
286335```
···288337**Record key:** `keyId` (user-chosen identifier)
289338290339**Fields:**
291291-- `keyId`: Unique identifier for this key
340340+- `keyId`: Unique identifier (e.g., `credential-helper-default`, `ci-key-1`)
292341- `keyType`: Algorithm (ecdsa-p256, ed25519, rsa-2048, rsa-4096)
293342- `publicKey`: PEM-encoded public key
294343- `validFrom`: Key becomes valid at this time
295295-- `expiresAt`: Key expires at this time (null = no expiry)
296296-- `revoked`: Key has been revoked (true/false)
297297-- `purpose`: Array of purposes (image-signing, sbom-signing, etc.)
344344+- `expiresAt`: Key expires (null = no expiry)
345345+- `revoked`: Revocation status
346346+- `purpose`: Key purposes (image-signing, sbom-signing, etc.)
347347+- `deviceId`: Optional device identifier
348348+- `comment`: Optional human-readable comment
298349299299-**io.atcr.signature** - Image signatures
350350+### io.atcr.signature - Image Signatures
300351301352```json
302353{
···304355 "repository": "alice/myapp",
305356 "digest": "sha256:abc123...",
306357 "signature": "MEUCIQDxH7...",
307307- "keyId": "alice-signing-key",
358358+ "keyId": "credential-helper-default",
308359 "signatureAlgorithm": "ecdsa-p256-sha256",
309360 "signedAt": "2025-10-20T12:34:56Z",
361361+ "format": "simple",
310362 "createdAt": "2025-10-20T12:34:56Z"
311363}
312364```
···315367316368**Fields:**
317369- `repository`: Image repository (alice/myapp)
318318-- `digest`: Manifest digest being signed
370370+- `digest`: Manifest digest being signed (sha256:...)
319371- `signature`: Base64-encoded signature bytes
320372- `keyId`: Reference to signing key record
321321-- `signatureAlgorithm`: Algorithm used for signing
322322-- `signedAt`: When signature was created
373373+- `signatureAlgorithm`: Algorithm used
374374+- `signedAt`: Timestamp of signature creation
375375+- `format`: Signature format (simple, cosign, notary)
376376+377377+## Verification
378378+379379+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.
380380+381381+### Integration with Docker/Kubernetes Workflows
382382+383383+**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.
384384+385385+**Reality check:**
386386+- Cosign looks for signatures as OCI referrers or attached manifests
387387+- Notary looks for signatures in registry's `_notary` endpoint
388388+- Kubernetes admission controllers (Sigstore Policy Controller, Ratify) use these tools
389389+- They won't find signatures stored only in ATProto
323390324324-### Plugin Implementation
391391+**The solution:** AppView implements the **OCI Referrers API** and serves ATProto signatures as OCI artifacts on-demand.
392392+393393+### How It Works: OCI Referrers API Bridge
325394326326-**notation-atproto** - Notary v2 plugin for ATProto
395395+When Cosign/Notary verify an image, they call the OCI Referrers API:
327396328328-**Trust store plugin:**
397397+```
398398+cosign verify atcr.io/alice/myapp:latest
399399+ ↓
400400+GET /v2/alice/myapp/referrers/sha256:abc123
401401+ ↓
402402+AppView:
403403+ 1. Queries alice's PDS for io.atcr.signature records
404404+ 2. Filters signatures matching digest sha256:abc123
405405+ 3. Transforms to OCI referrers format
406406+ 4. Returns as JSON
407407+ ↓
408408+Cosign receives OCI referrer manifest
409409+ ↓
410410+Verifies signature (works normally)
411411+```
412412+413413+**AppView endpoint implementation:**
414414+329415```go
330330-// Implements: notation trust store plugin spec
331331-// https://notaryproject.dev/docs/user-guides/how-to/plugin-management/
416416+// GET /v2/{owner}/{repo}/referrers/{digest}
417417+func (h *Handler) GetReferrers(w http.ResponseWriter, r *http.Request) {
418418+ owner := mux.Vars(r)["owner"]
419419+ digest := mux.Vars(r)["digest"]
332420333333-type ATProtoTrustStore struct {
334334- resolver *atproto.Resolver
335335- client *atproto.Client
336336-}
421421+ // 1. Resolve owner → DID → PDS
422422+ did, pds, err := h.resolver.ResolveIdentity(owner)
337423338338-// GetKeys resolves public keys for a given identity (DID)
339339-func (t *ATProtoTrustStore) GetKeys(did string) ([]PublicKey, error) {
340340- // 1. Resolve DID → PDS endpoint
341341- pds, err := t.resolver.ResolvePDS(did)
424424+ // 2. Query PDS for signatures matching digest
425425+ signatures, err := h.atproto.ListRecords(pds, did, "io.atcr.signature")
426426+ filtered := filterByDigest(signatures, digest)
342427343343- // 2. Query PDS for io.atcr.signing.key records
344344- records, err := t.client.ListRecords(pds, did, "io.atcr.signing.key")
428428+ // 3. Transform to OCI Index format
429429+ index := &ocispec.Index{
430430+ SchemaVersion: 2,
431431+ MediaType: ocispec.MediaTypeImageIndex,
432432+ Manifests: []ocispec.Descriptor{},
433433+ }
345434346346- // 3. Filter active keys (not revoked, not expired)
347347- keys := []PublicKey{}
348348- for _, record := range records {
349349- if !record.Revoked && !record.Expired() {
350350- keys = append(keys, ParsePublicKey(record.PublicKey))
351351- }
435435+ for _, sig := range filtered {
436436+ index.Manifests = append(index.Manifests, ocispec.Descriptor{
437437+ MediaType: "application/vnd.oci.image.manifest.v1+json",
438438+ Digest: sig.Digest,
439439+ Size: sig.Size,
440440+ ArtifactType: "application/vnd.dev.cosign.simplesigning.v1+json",
441441+ Annotations: map[string]string{
442442+ "dev.sigstore.cosign.signature": sig.Signature,
443443+ "io.atcr.keyId": sig.KeyId,
444444+ "io.atcr.signedAt": sig.SignedAt,
445445+ "io.atcr.source": fmt.Sprintf("at://%s/io.atcr.signature/%s", did, sig.Rkey),
446446+ },
447447+ })
352448 }
353449354354- return keys, nil
450450+ // 4. Return as JSON
451451+ w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex)
452452+ json.NewEncoder(w).Encode(index)
355453}
356454```
357455358358-**Signature store plugin:**
359359-```go
360360-// Store signature in user's PDS
361361-func (s *ATProtoSignatureStore) StoreSignature(sig Signature) error {
362362- // 1. Get OAuth token for user's PDS
363363- token, err := s.oauthClient.GetToken()
456456+**Benefits:**
457457+- ✅ **No dual storage** - signatures only in ATProto
458458+- ✅ **Standard tools work** - Cosign, Notary, Kubernetes admission controllers
459459+- ✅ **Single source of truth** - ATProto PDS
460460+- ✅ **On-demand transformation** - only when needed
461461+- ✅ **Offline verification** - can cache public keys
364462365365- // 2. Create signature record
366366- record := SignatureRecord{
367367- Type: "io.atcr.signature",
368368- Repository: sig.Repository,
369369- Digest: sig.Digest,
370370- Signature: base64.Encode(sig.Bytes),
371371- KeyId: sig.KeyId,
372372- SignatureAlgorithm: sig.Algorithm,
373373- SignedAt: time.Now(),
374374- }
463463+**Trade-offs:**
464464+- ⚠️ AppView must be reachable during verification (but already required for image pulls)
465465+- ⚠️ Transformation overhead (minimal - just JSON formatting)
375466376376- // 3. Generate record key (hash of digest + keyId)
377377- rkey := sha256.Sum256([]byte(sig.Digest + sig.KeyId))
467467+### Alternative Approaches
378468379379- // 4. Write to PDS
380380- err = s.client.PutRecord(pds, did, "io.atcr.signature", hex.Encode(rkey), record)
469469+#### Option 1: Dual Storage (Not Recommended)
381470382382- return err
383383-}
471471+Store signatures in BOTH ATProto AND OCI registry:
384472385385-// Retrieve signatures for a digest
386386-func (s *ATProtoSignatureStore) GetSignatures(did, digest string) ([]Signature, error) {
387387- // Query PDS for matching signatures
388388- records, err := s.client.ListRecords(pds, did, "io.atcr.signature")
473473+```go
474474+// In credential helper or AppView
475475+func StoreSignature(sig Signature) error {
476476+ // 1. Store in ATProto (user's PDS or hold's PDS)
477477+ err := storeInATProto(sig)
389478390390- // Filter by digest
391391- sigs := []Signature{}
392392- for _, record := range records {
393393- if record.Digest == digest {
394394- sigs = append(sigs, ParseSignature(record))
395395- }
396396- }
479479+ // 2. ALSO store as OCI artifact in registry
480480+ err = storeAsOCIReferrer(sig)
397481398398- return sigs, nil
482482+ return err
399483}
400484```
401485402402-**Plugin installation:**
403403-```bash
404404-# Install notation CLI
405405-brew install notation
406406-407407-# Install ATProto plugin
408408-notation plugin install notation-atproto --version v1.0.0
409409-410410-# Configure trust policy
411411-cat > ~/.config/notation/trustpolicy.json <<EOF
486486+**OCI Referrer format:**
487487+```json
412488{
413413- "version": "1.0",
414414- "trustPolicies": [
415415- {
416416- "name": "atcr-images",
417417- "registryScopes": ["atcr.io/*/*"],
418418- "signatureVerification": {"level": "strict"},
419419- "trustStores": ["atproto:default"],
420420- "trustedIdentities": ["did:plc:*"]
489489+ "schemaVersion": 2,
490490+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
491491+ "artifactType": "application/vnd.dev.cosign.simplesigning.v1+json",
492492+ "subject": {
493493+ "digest": "sha256:abc123...",
494494+ "mediaType": "application/vnd.oci.image.manifest.v1+json"
495495+ },
496496+ "layers": [{
497497+ "mediaType": "application/vnd.dev.cosign.simplesigning.v1+json",
498498+ "digest": "sha256:sig...",
499499+ "annotations": {
500500+ "dev.sigstore.cosign.signature": "MEUCIQDx...",
501501+ "io.atcr.source": "atproto://did:plc:alice123/io.atcr.signature/..."
421502 }
422422- ]
503503+ }]
423504}
424424-EOF
425505```
426506427427-## User Workflows
428428-429429-### Initial Setup
430430-431431-```bash
432432-# 1. Generate signing key pair
433433-notation key generate --id alice-signing-key --type ecdsa
507507+**Benefits:**
508508+- ✅ Works with standard Cosign verification
509509+- ✅ Kubernetes admission controllers work out of box
510510+- ✅ ATProto signatures still available for discovery
511511+- ✅ Cross-reference via `io.atcr.source` annotation
434512435435-# Private key stored in: ~/.config/notation/keys/
436436-# Public key extracted by plugin
513513+**Trade-offs:**
514514+- ❌ Duplicate storage (ATProto + OCI)
515515+- ❌ Consistency issues (what if one write fails?)
516516+- ❌ Signatures tied to specific registry
437517438438-# 2. Publish public key to PDS
439439-notation-atproto key publish alice-signing-key
518518+#### Option 2: Custom Admission Controller
440519441441-# Plugin uploads io.atcr.signing.key record to alice's PDS
442442-# Requires OAuth authentication to alice's PDS
520520+Write Kubernetes admission controller that understands ATProto:
443521444444-# 3. Verify key is published
445445-notation-atproto key list
446446-447447-# Output:
448448-# alice-signing-key (ecdsa-p256) - Active
449449-# Published: 2025-10-20T12:00:00Z
450450-# Expires: 2026-10-20T12:00:00Z
451451-# DID: did:plc:alice123
522522+```yaml
523523+# admission-controller deployment
524524+apiVersion: v1
525525+kind: ConfigMap
526526+metadata:
527527+ name: atcr-policy
528528+data:
529529+ policy.yaml: |
530530+ policies:
531531+ - name: require-atcr-signatures
532532+ images:
533533+ - "atcr.io/*/*"
534534+ verification:
535535+ method: atproto
536536+ requireSignature: true
452537```
453538454454-### Signing Images
539539+**Benefits:**
540540+- ✅ Native ATProto support
541541+- ✅ No OCI conversion needed
542542+- ✅ Can enforce ATCR-specific policies
455543456456-```bash
457457-# Sign an image after pushing
458458-docker push atcr.io/alice/myapp:latest
544544+**Trade-offs:**
545545+- ❌ Doesn't work with standard tools (Cosign, Notary)
546546+- ❌ Additional infrastructure to maintain
547547+- ❌ Limited ecosystem integration
459548460460-notation sign atcr.io/alice/myapp:latest \
461461- --key alice-signing-key \
462462- --plugin atproto
549549+#### Recommendation
463550464464-# Plugin:
465465-# 1. Reads private key from ~/.config/notation/keys/
466466-# 2. Signs manifest digest
467467-# 3. Uploads signature to alice's PDS (io.atcr.signature record)
468468-# 4. Returns success
551551+**Primary approach: OCI Referrers API Bridge**
552552+- Implement `/v2/{owner}/{repo}/referrers/{digest}` in AppView
553553+- Query ATProto on-demand and transform to OCI format
554554+- Works with Cosign, Notary, Kubernetes admission controllers
555555+- No duplicate storage, single source of truth
469556470470-# Output:
471471-# Successfully signed atcr.io/alice/myapp:latest
472472-# Signature stored in PDS: did:plc:alice123
473473-```
557557+**Why this works:**
558558+- Cosign/Notary just make HTTP GET requests to the registry
559559+- AppView is already the registry - just add one endpoint
560560+- Transformation is simple (ATProto record → OCI descriptor)
561561+- Signatures stay in ATProto where they belong
474562475475-### Verifying Images
563563+### Cosign Verification (OCI Referrers API)
476564477565```bash
478478-# Verify before running
479479-notation verify atcr.io/alice/myapp:latest
566566+# Standard Cosign works out of the box:
567567+cosign verify atcr.io/alice/myapp:latest \
568568+ --key <(atcr-cli key export alice credential-helper-default)
480569481481-# Plugin:
482482-# 1. Resolves "alice" → did:plc:alice123 → pds.alice.com
483483-# 2. Fetches manifest digest
484484-# 3. Queries alice's PDS for signatures
485485-# 4. Fetches public key from io.atcr.signing.key
486486-# 5. Verifies signature cryptographically
487487-# 6. Checks trust policy
570570+# What happens:
571571+# 1. Cosign queries: GET /v2/alice/myapp/referrers/sha256:abc123
572572+# 2. AppView fetches signatures from alice's PDS
573573+# 3. AppView returns OCI referrers index
574574+# 4. Cosign downloads signature artifact
575575+# 5. Cosign verifies with public key
576576+# 6. Success!
488577489489-# Output:
490490-# ✓ Signature verification succeeded
491491-#
492492-# Signed by: did:plc:alice123
493493-# Key ID: alice-signing-key
494494-# Signed at: 2025-10-20T12:34:56Z
495495-# Algorithm: ecdsa-p256-sha256
578578+# Or with public key inline:
579579+cosign verify atcr.io/alice/myapp:latest --key '-----BEGIN PUBLIC KEY-----
580580+MFkwEwYHKoZI...
581581+-----END PUBLIC KEY-----'
496582```
497583498498-### Key Rotation
584584+**Fetching public keys from ATProto:**
585585+586586+Public keys are stored in ATProto records and can be fetched via standard XRPC:
499587500588```bash
501501-# Generate new key
502502-notation key generate --id alice-signing-key-2 --type ecdsa
589589+# Query for public keys
590590+curl "https://atcr.io/xrpc/com.atproto.repo.listRecords?\
591591+ repo=did:plc:alice123&\
592592+ collection=io.atcr.signing.key"
503593504504-# Publish new key
505505-notation-atproto key publish alice-signing-key-2
594594+# Extract public key and save as PEM
595595+# Then use in Cosign:
596596+cosign verify atcr.io/alice/myapp:latest --key alice.pub
597597+```
506598507507-# Re-sign images with new key
508508-notation sign atcr.io/alice/myapp:latest --key alice-signing-key-2
599599+### Kubernetes Policy Example (OCI Referrers API)
509600510510-# Revoke old key
511511-notation-atproto key revoke alice-signing-key
512512-513513-# Plugin updates io.atcr.signing.key record:
514514-# { ..., "revoked": true, "revokedAt": "2025-11-01T..." }
515515-516516-# Old signatures still exist but verification will fail
517517-# (revoked key = untrusted)
601601+```yaml
602602+# Sigstore Policy Controller
603603+apiVersion: policy.sigstore.dev/v1beta1
604604+kind: ClusterImagePolicy
605605+metadata:
606606+ name: atcr-images-must-be-signed
607607+spec:
608608+ images:
609609+ - glob: "atcr.io/*/*"
610610+ authorities:
611611+ - key:
612612+ # Public key from ATProto record
613613+ data: |
614614+ -----BEGIN PUBLIC KEY-----
615615+ MFkwEwYHKoZI...
616616+ -----END PUBLIC KEY-----
518617```
519618520520-### Key Expiration
619619+**How it works:**
620620+1. Pod tries to run `atcr.io/alice/myapp:latest`
621621+2. Policy Controller intercepts
622622+3. Queries registry for OCI referrers (finds signature)
623623+4. Verifies signature with public key
624624+5. Allows pod if valid
521625522522-```bash
523523-# Generate key with expiration
524524-notation key generate \
525525- --id alice-signing-key \
526526- --type ecdsa \
527527- --expires 365d # 1 year
626626+### Trust Policies
528627529529-# Publish with expiration
530530-notation-atproto key publish alice-signing-key
628628+Define what signatures are required for image execution:
531629532532-# PDS record:
533533-# {
534534-# "validFrom": "2025-10-20T12:00:00Z",
535535-# "expiresAt": "2026-10-20T12:00:00Z"
536536-# }
630630+```yaml
631631+# ~/.atcr/trust-policy.yaml
632632+policies:
633633+ - name: production-images
634634+ scope: "atcr.io/alice/prod-*"
635635+ require:
636636+ - signature: true
637637+ - keyIds: ["ci-key-1", "alice-release-key"]
638638+ action: enforce # block, audit, or allow
537639538538-# After expiration, verification fails:
539539-notation verify atcr.io/alice/myapp:latest
540540-# ✗ Signature verification failed
541541-# Signing key expired on 2026-10-20T12:00:00Z
640640+ - name: dev-images
641641+ scope: "atcr.io/alice/dev-*"
642642+ require:
643643+ - signature: false
644644+ action: audit
542645```
543646647647+**Integration points:**
648648+- Kubernetes admission controller
649649+- Docker Content Trust equivalent
650650+- CI/CD pipeline gates
651651+544652## Security Considerations
545653546546-### Key Storage
654654+### Key Storage Security
547655548548-**Private keys must be protected:**
549549-- File permissions: `0600` (owner read/write only)
550550-- Use OS keychain when possible (macOS Keychain, Windows Credential Manager)
551551-- Consider hardware tokens (YubiKey, TPM) for production
552552-- Never commit private keys to git
656656+**OS keychain benefits:**
657657+- ✅ Encrypted storage
658658+- ✅ Access control (requires user password/biometric)
659659+- ✅ Auditing (macOS logs keychain access)
660660+- ✅ Hardware-backed on modern systems (Secure Enclave, TPM)
553661554554-**Public keys are public:**
555555-- Stored in user's PDS (publicly readable)
556556-- Anyone can verify signatures
557557-- Revocation is public and immediate
662662+**Best practices:**
663663+- Generate keys on device (never transmitted)
664664+- Use hardware-backed storage when available
665665+- Require user approval for key access (biometric/password)
666666+- Rotate keys periodically (e.g., annually)
558667559668### Trust Model
560669561670**What signatures prove:**
562562-- ✅ Image manifest hasn't been tampered with since signing
563563-- ✅ Signer had access to private key at signing time
564564-- ✅ Signer's DID matches image owner (alice signed alice/myapp)
671671+- ✅ User had access to private key at signing time
672672+- ✅ Manifest digest matches what was signed
673673+- ✅ Signature created by specific key ID
674674+- ✅ Timestamp of signature creation
565675566676**What signatures don't prove:**
567677- ❌ Image is free of vulnerabilities
568678- ❌ Image contents are safe to run
569569-- ❌ Signer's identity is verified (depends on DID trust)
679679+- ❌ User's identity is verified (depends on DID trust)
680680+- ❌ Private key wasn't compromised
570681571571-**Trust anchors:**
572572-- Trust PDS to correctly serve signing key records
573573-- Trust DID resolution (PLC directory, did:web DNS)
574574-- Trust signature algorithms (ECDSA, Ed25519, RSA)
575575-- Trust user to protect their private keys
682682+**Trust dependencies:**
683683+- User protects their private key
684684+- OS keychain security
685685+- DID resolution accuracy (PLC directory, did:web)
686686+- PDS serves correct public key records
687687+- Signature algorithms remain secure
576688577577-### Key Compromise
689689+### Multi-Device Support
578690579579-If a private signing key is compromised:
691691+**Challenge:** User has multiple devices (laptop, desktop, CI/CD)
580692581581-```bash
582582-# 1. Immediately revoke the key
583583-notation-atproto key revoke alice-signing-key --reason "Key compromised"
693693+**Options:**
584694585585-# 2. Generate new key
586586-notation key generate --id alice-signing-key-new --type ecdsa
695695+1. **Separate keys per device:**
696696+ ```json
697697+ {
698698+ "keyId": "alice-macbook-pro",
699699+ "deviceId": "macbook-pro"
700700+ },
701701+ {
702702+ "keyId": "alice-desktop",
703703+ "deviceId": "desktop"
704704+ }
705705+ ```
706706+ - Pros: Best security (key compromise limited to one device)
707707+ - Cons: Need to trust signatures from any device
587708588588-# 3. Publish new key
589589-notation-atproto key publish alice-signing-key-new
709709+2. **Shared key via secure sync:**
710710+ - Export key from primary device
711711+ - Import to secondary devices
712712+ - Stored in each device's keychain
713713+ - Pros: Single key ID to trust
714714+ - Cons: More attack surface (key on multiple devices)
590715591591-# 4. Re-sign all images with new key
592592-for image in $(docker images --format "{{.Repository}}:{{.Tag}}"); do
593593- notation sign $image --key alice-signing-key-new
594594-done
716716+3. **Primary + secondary model:**
717717+ - Primary key on main device
718718+ - Secondary keys on other devices
719719+ - Trust policy requires primary key signature
720720+ - Pros: Flexible + secure
721721+ - Cons: More complex setup
595722596596-# 5. Alert users to only trust new key
597597-# (Old signatures will fail verification due to revocation)
598598-```
723723+**Recommendation:** Separate keys per device (Option 1) for security, with trust policy accepting any of user's keys.
599724600600-**Revocation is immediate:**
601601-- PDS record updated with `"revoked": true`
602602-- All verification attempts fail instantly
603603-- No need to update certificate revocation lists (CRLs)
604604-- ATProto record queries are always fresh
725725+### Key Compromise Response
605726606606-### Multiple Signatures
727727+If a device is lost or private key is compromised:
607728608608-Images can have multiple signatures:
729729+1. **Revoke the key** via AppView web UI or XRPC API
730730+ - Updates `io.atcr.signing.key` record: `"revoked": true`
731731+ - Revocation is atomic and immediate
609732610610-```bash
611611-# Alice signs with her key
612612-notation sign atcr.io/alice/myapp:latest --key alice-signing-key
733733+2. **Generate new key** on new/existing device
734734+ - Automatic on next `docker login` from secure device
735735+ - Credential helper generates new key pair
613736614614-# CI/CD system signs with separate key
615615-notation sign atcr.io/alice/myapp:latest --key ci-signing-key
737737+3. **Old signatures still exist but fail verification**
738738+ - Revoked key = untrusted
739739+ - No certificate revocation list (CRL) delays
740740+ - Globally visible within seconds
616741617617-# Both signatures stored in alice's PDS
618618-# Verification requires both (configurable in trust policy)
619619-```
742742+### CI/CD Signing
620743621621-**Trust policy:**
622622-```json
623623-{
624624- "trustPolicies": [{
625625- "name": "require-dual-signature",
626626- "registryScopes": ["atcr.io/alice/*"],
627627- "signatureVerification": {
628628- "level": "strict",
629629- "verifyTimestamp": true,
630630- "override": {
631631- "all": ["alice-signing-key", "ci-signing-key"]
632632- }
633633- }
634634- }]
635635-}
744744+For automated builds, use standard Cosign in your CI pipeline:
745745+746746+```yaml
747747+# .github/workflows/build.yml
748748+steps:
749749+ - name: Push image
750750+ run: docker push atcr.io/alice/myapp:latest
751751+752752+ - name: Sign with Cosign
753753+ run: cosign sign atcr.io/alice/myapp:latest --key ${{ secrets.COSIGN_KEY }}
636754```
637755756756+**Key management:**
757757+- Generate Cosign key pair: `cosign generate-key-pair`
758758+- Store private key in CI secrets (GitHub Actions, GitLab CI, etc.)
759759+- Publish public key to PDS via XRPC or AppView web UI
760760+- Cosign stores signature via registry's OCI API
761761+- AppView automatically stores in ATProto
762762+763763+**Or use automatic signing:**
764764+- Configure credential helper in CI environment
765765+- Signatures happen automatically on push
766766+- No explicit signing step needed
767767+638768## Implementation Roadmap
639769640640-### Phase 1: Core Plugin (4-6 weeks)
770770+### Phase 1: Core Signing (2-3 weeks)
641771642642-**Week 1-2: Trust store plugin**
643643-- Implement DID resolution
644644-- Query `io.atcr.signing.key` records
645645-- Parse and validate public keys
646646-- Handle revocation and expiration
772772+**Week 1: Credential helper key management**
773773+- Generate ECDSA key pair on first run
774774+- Store private key in OS keychain
775775+- Create `io.atcr.signing.key` record in PDS
776776+- Handle key rotation
777777+778778+**Week 2: Signing integration**
779779+- Sign manifest digest after authentication
780780+- Send signature to AppView via XRPC
781781+- AppView stores in user's PDS or hold's PDS
782782+- Error handling and retries
783783+784784+**Week 3: OCI Referrers API**
785785+- Implement `GET /v2/{owner}/{repo}/referrers/{digest}` in AppView
786786+- Query ATProto for signatures
787787+- Transform to OCI Index format
788788+- Return Cosign-compatible artifacts
789789+- Test with `cosign verify`
647790648648-**Week 3-4: Signature store plugin**
649649-- OAuth integration for PDS writes
650650-- Create `io.atcr.signature` records
651651-- Query signatures for verification
652652-- Handle record key generation
791791+### Phase 2: Enhanced Features (2-3 weeks)
653792654654-**Week 5-6: Integration testing**
655655-- End-to-end sign/verify workflows
656656-- Key rotation scenarios
793793+**Key management (credential helper):**
794794+- Key rotation support
657795- Revocation handling
658658-- Multi-signature support
796796+- Device identification
797797+- Key expiration
798798+799799+**Signature storage:**
800800+- Handle manual Cosign signing (via OCI API)
801801+- Store signatures from both automatic and manual flows
802802+- Signature deduplication
803803+- Signature audit logs
804804+805805+**AppView endpoints:**
806806+- XRPC endpoints for key/signature queries
807807+- Web UI for viewing keys and signatures
808808+- Key revocation via web interface
809809+810810+### Phase 3: Kubernetes Integration (2-3 weeks)
811811+812812+**Admission controller setup:**
813813+- Documentation for Sigstore Policy Controller
814814+- Example policies for ATCR images
815815+- Public key management (fetch from ATProto)
816816+- Integration testing with real clusters
817817+818818+**Advanced features:**
819819+- Signature caching in AppView (reduce PDS queries)
820820+- Multi-signature support (require N signatures)
821821+- Timestamp verification
822822+- Signature expiration policies
823823+824824+### Phase 4: UI Integration (1-2 weeks)
825825+826826+**AppView web UI:**
827827+- Show signature status on repository pages
828828+- List signing keys for users
829829+- Revoke keys via web interface
830830+- Signature verification badges
831831+832832+## Comparison: Automatic vs Manual Signing
833833+834834+| Feature | Automatic (Credential Helper) | Manual (Standard Cosign) |
835835+|---------|-------------------------------|--------------------------|
836836+| **User action** | Zero - happens on push | `cosign sign` after push |
837837+| **Key management** | Automatic generation/storage | User manages keys |
838838+| **Consistency** | Every image signed | Easy to forget |
839839+| **Setup** | Works with credential helper | Install Cosign, generate keys |
840840+| **CI/CD** | Automatic if cred helper configured | Explicit signing step |
841841+| **Flexibility** | Opinionated defaults | Full control over workflow |
842842+| **Use case** | Most users, simple workflows | Advanced users, custom workflows |
659843660660-### Phase 2: Tooling (2-3 weeks)
844844+**Recommendation:**
845845+- **Start with automatic**: Best UX, works for most users
846846+- **Use manual** for: CI/CD pipelines, hardware tokens, custom signing workflows
661847662662-**CLI commands:**
848848+## Complete Workflow Summary
849849+850850+### Option 1: Automatic Signing (Recommended)
851851+663852```bash
664664-notation-atproto key generate
665665-notation-atproto key publish
666666-notation-atproto key list
667667-notation-atproto key revoke
668668-notation-atproto signature list <image>
669669-notation-atproto signature inspect <image>
853853+# Setup (one time)
854854+docker login atcr.io
855855+# → Credential helper generates ECDSA key pair
856856+# → Private key in OS keychain
857857+# → Public key published to PDS
858858+859859+# Push (automatic signing)
860860+docker push atcr.io/alice/myapp:latest
861861+# → Image pushed and signed automatically
862862+# → No extra commands!
863863+864864+# Verify (standard Cosign)
865865+cosign verify atcr.io/alice/myapp:latest --key alice.pub
866866+# → Cosign queries OCI Referrers API
867867+# → AppView returns ATProto signatures as OCI artifacts
868868+# → Verification succeeds ✓
670869```
671870672672-**Helper utilities:**
673673-- Bulk re-signing for key rotation
674674-- Signature audit logs
675675-- Trust policy generators
676676-- Key lifecycle management
871871+### Option 2: Manual Signing (DIY)
677872678678-### Phase 3: AppView Integration (2-3 weeks)
873873+```bash
874874+# Push image
875875+docker push atcr.io/alice/myapp:latest
679876680680-**Web UI features:**
681681-- Display signature status on repository pages
682682-- Show signing keys for users
683683-- Signature verification badges
684684-- Key management interface
877877+# Sign with Cosign
878878+cosign sign atcr.io/alice/myapp:latest --key cosign.key
879879+# → Cosign stores via OCI API
880880+# → AppView stores in ATProto
685881686686-**API endpoints:**
687687-- `GET /v2/alice/myapp/signatures` - List signatures for image
688688-- `GET /v2/alice/keys` - List user's signing keys
689689-- `POST /v2/alice/keys/revoke` - Revoke key via web UI
882882+# Verify (same as automatic)
883883+cosign verify atcr.io/alice/myapp:latest --key cosign.pub
884884+```
690885691691-### Phase 4: Advanced Features (ongoing)
886886+### Kubernetes (Standard Admission Controller)
692887693693-**Hardware token support:**
694694-- YubiKey integration
695695-- TPM-backed keys
696696-- Hardware-backed keystores
888888+```yaml
889889+# Sigstore Policy Controller (standard)
890890+apiVersion: policy.sigstore.dev/v1beta1
891891+kind: ClusterImagePolicy
892892+metadata:
893893+ name: atcr-signed-only
894894+spec:
895895+ images:
896896+ - glob: "atcr.io/*/*"
897897+ authorities:
898898+ - key:
899899+ data: |
900900+ -----BEGIN PUBLIC KEY-----
901901+ [Alice's public key from ATProto]
902902+ -----END PUBLIC KEY-----
903903+```
697904698698-**Timestamp verification:**
699699-- Trusted timestamp authorities
700700-- Prove signature was created at specific time
701701-- Long-term signature validity
905905+**How admission control works:**
906906+1. Pod tries to start with `atcr.io/alice/myapp:latest`
907907+2. Policy Controller intercepts
908908+3. Calls `GET /v2/alice/myapp/referrers/sha256:abc123`
909909+4. AppView returns signatures from ATProto
910910+5. Policy Controller verifies with public key
911911+6. Pod allowed to start ✓
702912703703-**SBOM signing:**
704704-- Sign SBOMs with same keys
705705-- Link SBOM signatures to image signatures
706706-- Unified verification workflow
913913+### Key Design Points
707914708708-## Comparison: Cosign vs Notary v2 for ATCR
915915+**User experience:**
916916+- ✅ Two options: automatic (credential helper) or manual (standard Cosign)
917917+- ✅ Standard verification tools work (Cosign, Notary, Kubernetes)
918918+- ✅ No custom ATCR-specific signing commands
919919+- ✅ User-controlled keys (OS keychain or self-managed)
709920710710-| Feature | Cosign | Notary v2 | Winner |
711711-|---------|--------|-----------|--------|
712712-| **ATProto integration** | Requires OIDC bridge | Plugin system | ✅ Notary |
713713-| **Key format flexibility** | Limited | Extensible | ✅ Notary |
714714-| **Custom storage** | OCI only | Pluggable | ✅ Notary |
715715-| **Infrastructure needs** | Fulcio + Rekor | None | ✅ Notary |
716716-| **Keyless signing** | Yes (complex) | No | ⚠️ Cosign* |
717717-| **Ecosystem maturity** | High | Medium | ⚠️ Cosign* |
718718-| **CLI simplicity** | Very simple | Simple | ⚠️ Cosign* |
719719-| **Plugin development** | N/A | Required | ⚠️ Mixed |
921921+**Architecture:**
922922+- **Signing**: Client-side only (credential helper or Cosign)
923923+- **Storage**: ATProto (user's PDS or hold's PDS via `io.atcr.signature`)
924924+- **Verification**: Standard tools via OCI Referrers API bridge
925925+- **Bridge**: AppView transforms ATProto → OCI format on-demand
720926721721-*Cosign advantages don't outweigh ATProto incompatibilities
722722-723723-**Recommendation: Notary v2 with ATProto plugin**
927927+**Why this works:**
928928+- ✅ No server-side signing needed (impossible with ATProto constraints)
929929+- ✅ Signatures discoverable via ATProto
930930+- ✅ No duplicate storage (single source of truth)
931931+- ✅ Standard OCI compliance for verification
724932725933## References
726934935935+### Signing & Verification
936936+- [Sigstore Cosign](https://github.com/sigstore/cosign)
727937- [Notary v2 Specification](https://notaryproject.dev/)
728728-- [Notation CLI](https://github.com/notaryproject/notation)
729729-- [Notary Plugin Specification](https://notaryproject.dev/docs/user-guides/how-to/plugin-management/)
730730-- [Sigstore Cosign](https://github.com/sigstore/cosign) (for comparison)
731731-- [ATProto Specification](https://atproto.com/)
938938+- [Cosign Signature Specification](https://github.com/sigstore/cosign/blob/main/specs/SIGNATURE_SPEC.md)
939939+940940+### OCI & Registry
941941+- [OCI Distribution Specification](https://github.com/opencontainers/distribution-spec)
942942+- [OCI Referrers API](https://github.com/oras-project/artifacts-spec/blob/main/manifest-referrers-api.md)
732943- [OCI Artifacts](https://github.com/opencontainers/artifacts)
733733-- [RFC 7515 - JSON Web Signature](https://datatracker.ietf.org/doc/html/rfc7515) (signature formats)
944944+945945+### ATProto
946946+- [ATProto Specification](https://atproto.com/)
947947+- [ATProto Repository Specification](https://atproto.com/specs/repository)
948948+949949+### Key Management
950950+- [Docker Credential Helpers](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers)
951951+- [macOS Keychain Services](https://developer.apple.com/documentation/security/keychain_services)
952952+- [Windows Credential Manager](https://docs.microsoft.com/en-us/windows/security/identity-protection/credential-guard/)
953953+- [Linux Secret Service API](https://specifications.freedesktop.org/secret-service/)
954954+955955+### Kubernetes Integration
956956+- [Sigstore Policy Controller](https://docs.sigstore.dev/policy-controller/overview/)
957957+- [Ratify (Notary verification for Kubernetes)](https://ratify.dev/)
+43
pkg/appview/db/queries.go
···309309 return repos, nil
310310}
311311312312+// GetRepositoryMetadata retrieves metadata for a repository from its most recent manifest
313313+func GetRepositoryMetadata(db *sql.DB, did string, repository string) (title, description, sourceURL, documentationURL, licenses, iconURL string, err error) {
314314+ var titleNull, descriptionNull, sourceURLNull, documentationURLNull, licensesNull, iconURLNull sql.NullString
315315+316316+ err = db.QueryRow(`
317317+ SELECT title, description, source_url, documentation_url, licenses, icon_url
318318+ FROM manifests
319319+ WHERE did = ? AND repository = ?
320320+ ORDER BY created_at DESC
321321+ LIMIT 1
322322+ `, did, repository).Scan(&titleNull, &descriptionNull, &sourceURLNull, &documentationURLNull, &licensesNull, &iconURLNull)
323323+324324+ if err == sql.ErrNoRows {
325325+ // No manifests found - return empty strings
326326+ return "", "", "", "", "", "", nil
327327+ }
328328+ if err != nil {
329329+ return "", "", "", "", "", "", err
330330+ }
331331+332332+ // Convert NullString to string
333333+ if titleNull.Valid {
334334+ title = titleNull.String
335335+ }
336336+ if descriptionNull.Valid {
337337+ description = descriptionNull.String
338338+ }
339339+ if sourceURLNull.Valid {
340340+ sourceURL = sourceURLNull.String
341341+ }
342342+ if documentationURLNull.Valid {
343343+ documentationURL = documentationURLNull.String
344344+ }
345345+ if licensesNull.Valid {
346346+ licenses = licensesNull.String
347347+ }
348348+ if iconURLNull.Valid {
349349+ iconURL = iconURLNull.String
350350+ }
351351+352352+ return title, description, sourceURL, documentationURL, licenses, iconURL, nil
353353+}
354354+312355// GetUserByDID retrieves a user by DID
313356func GetUserByDID(db *sql.DB, did string) (*User, error) {
314357 var user User
+120
pkg/appview/db/queries_test.go
···11+package db
22+33+import (
44+ "testing"
55+ "time"
66+)
77+88+func TestGetRepositoryMetadata(t *testing.T) {
99+ // Create in-memory test database
1010+ db, err := InitDB(":memory:")
1111+ if err != nil {
1212+ t.Fatalf("Failed to init database: %v", err)
1313+ }
1414+ defer db.Close()
1515+1616+ // Insert test user
1717+ testUser := &User{
1818+ DID: "did:plc:test123",
1919+ Handle: "testuser.bsky.social",
2020+ PDSEndpoint: "https://test.pds.example.com",
2121+ Avatar: "",
2222+ LastSeen: time.Now(),
2323+ }
2424+ if err := UpsertUser(db, testUser); err != nil {
2525+ t.Fatalf("Failed to insert user: %v", err)
2626+ }
2727+2828+ // Test 1: No manifests - should return empty strings
2929+ title, description, sourceURL, documentationURL, licenses, iconURL, err := GetRepositoryMetadata(db, testUser.DID, "nonexistent")
3030+ if err != nil {
3131+ t.Fatalf("Expected no error for nonexistent repo, got: %v", err)
3232+ }
3333+ if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" {
3434+ t.Error("Expected all empty strings for nonexistent repository")
3535+ }
3636+3737+ // Test 2: Insert manifest with metadata
3838+ _, err = db.Exec(`
3939+ INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at,
4040+ title, description, source_url, documentation_url, licenses, icon_url)
4141+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4242+ `, testUser.DID, "myapp", "sha256:abc123", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json",
4343+ time.Now().Add(-2*time.Hour),
4444+ "My App", "A cool application", "https://github.com/user/myapp", "https://docs.example.com", "MIT", "https://example.com/icon.png")
4545+ if err != nil {
4646+ t.Fatalf("Failed to insert manifest: %v", err)
4747+ }
4848+4949+ // Test 3: Retrieve metadata
5050+ title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp")
5151+ if err != nil {
5252+ t.Fatalf("Failed to get repository metadata: %v", err)
5353+ }
5454+5555+ if title != "My App" {
5656+ t.Errorf("Expected title 'My App', got '%s'", title)
5757+ }
5858+ if description != "A cool application" {
5959+ t.Errorf("Expected description 'A cool application', got '%s'", description)
6060+ }
6161+ if sourceURL != "https://github.com/user/myapp" {
6262+ t.Errorf("Expected sourceURL 'https://github.com/user/myapp', got '%s'", sourceURL)
6363+ }
6464+ if documentationURL != "https://docs.example.com" {
6565+ t.Errorf("Expected documentationURL 'https://docs.example.com', got '%s'", documentationURL)
6666+ }
6767+ if licenses != "MIT" {
6868+ t.Errorf("Expected licenses 'MIT', got '%s'", licenses)
6969+ }
7070+ if iconURL != "https://example.com/icon.png" {
7171+ t.Errorf("Expected iconURL 'https://example.com/icon.png', got '%s'", iconURL)
7272+ }
7373+7474+ // Test 4: Insert newer manifest with different metadata
7575+ _, err = db.Exec(`
7676+ INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at,
7777+ title, description, source_url, documentation_url, licenses, icon_url)
7878+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
7979+ `, testUser.DID, "myapp", "sha256:def456", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json",
8080+ time.Now(), // Most recent
8181+ "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")
8282+ if err != nil {
8383+ t.Fatalf("Failed to insert newer manifest: %v", err)
8484+ }
8585+8686+ // Test 5: Should return metadata from most recent manifest
8787+ title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp")
8888+ if err != nil {
8989+ t.Fatalf("Failed to get repository metadata: %v", err)
9090+ }
9191+9292+ if title != "My App v2" {
9393+ t.Errorf("Expected title from newest manifest 'My App v2', got '%s'", title)
9494+ }
9595+ if description != "An even cooler application" {
9696+ t.Errorf("Expected description from newest manifest, got '%s'", description)
9797+ }
9898+ if licenses != "Apache-2.0" {
9999+ t.Errorf("Expected licenses 'Apache-2.0', got '%s'", licenses)
100100+ }
101101+102102+ // Test 6: Manifest with NULL metadata fields
103103+ _, err = db.Exec(`
104104+ INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
105105+ VALUES (?, ?, ?, ?, ?, ?, ?)
106106+ `, testUser.DID, "minimal-app", "sha256:minimal", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json", time.Now())
107107+ if err != nil {
108108+ t.Fatalf("Failed to insert minimal manifest: %v", err)
109109+ }
110110+111111+ // Test 7: Should handle NULL fields gracefully
112112+ title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "minimal-app")
113113+ if err != nil {
114114+ t.Fatalf("Failed to get repository metadata for minimal app: %v", err)
115115+ }
116116+117117+ if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" {
118118+ t.Error("Expected all empty strings for manifest with NULL metadata fields")
119119+ }
120120+}