diff --git a/.env.appview.example b/.env.appview.example index 398da9e..171b883 100644 --- a/.env.appview.example +++ b/.env.appview.example @@ -71,6 +71,21 @@ ATCR_UI_ENABLED=true # Log formatter: text, json (default: text) # ATCR_LOG_FORMATTER=text +# ============================================================================== +# Hold Health Check Configuration +# ============================================================================== + +# How often to check health of hold endpoints in the background (default: 15m) +# Queries database for unique hold endpoints and checks if they're reachable +# Examples: 5m, 15m, 30m, 1h +# ATCR_HEALTH_CHECK_INTERVAL=15m + +# How long to cache health check results (default: 15m) +# Cached results avoid redundant health checks on page renders +# Should be >= ATCR_HEALTH_CHECK_INTERVAL for efficiency +# Examples: 15m, 30m, 1h +# ATCR_HEALTH_CACHE_TTL=15m + # ============================================================================== # Jetstream Configuration (ATProto event streaming) # ============================================================================== diff --git a/cmd/appview/serve.go b/cmd/appview/serve.go index 8d7599a..0d1efa9 100644 --- a/cmd/appview/serve.go +++ b/cmd/appview/serve.go @@ -75,19 +75,39 @@ func serveRegistry(cmd *cobra.Command, args []string) error { // Initialize hold health checker fmt.Println("Initializing hold health checker...") - cacheTTL := 15 * time.Minute // Cache TTL from user requirements + + // Parse health check cache TTL from environment (default: 15m) + cacheTTL := 15 * time.Minute + if cacheTTLStr := os.Getenv("ATCR_HEALTH_CACHE_TTL"); cacheTTLStr != "" { + if parsed, err := time.ParseDuration(cacheTTLStr); err == nil { + cacheTTL = parsed + } else { + fmt.Printf("Warning: Invalid ATCR_HEALTH_CACHE_TTL '%s', using default 15m\n", cacheTTLStr) + } + } + healthChecker := holdhealth.NewChecker(cacheTTL) // Start background health check worker - refreshInterval := 5 * time.Minute // Refresh every 5 minutes + // Parse refresh interval from environment (default: 15m) + refreshInterval := 15 * time.Minute + if refreshIntervalStr := os.Getenv("ATCR_HEALTH_CHECK_INTERVAL"); refreshIntervalStr != "" { + if parsed, err := time.ParseDuration(refreshIntervalStr); err == nil { + refreshInterval = parsed + } else { + fmt.Printf("Warning: Invalid ATCR_HEALTH_CHECK_INTERVAL '%s', using default 15m\n", refreshIntervalStr) + } + } + + startupDelay := 5 * time.Second // Wait for hold services to start (Docker compose) dbAdapter := holdhealth.NewDBAdapter(uiDatabase) - healthWorker := holdhealth.NewWorker(healthChecker, dbAdapter, refreshInterval) + healthWorker := holdhealth.NewWorkerWithStartupDelay(healthChecker, dbAdapter, refreshInterval, startupDelay) // Create context for worker lifecycle management workerCtx, workerCancel := context.WithCancel(context.Background()) defer workerCancel() // Ensure context is cancelled on all exit paths healthWorker.Start(workerCtx) - fmt.Println("Hold health worker started (5min refresh interval, 15min cache TTL)") + fmt.Printf("Hold health worker started (5s startup delay, %s refresh interval, %s cache TTL)\n", refreshInterval, cacheTTL) // Initialize OAuth components fmt.Println("Initializing OAuth components...") diff --git a/docs/IMAGE_SIGNING.md b/docs/IMAGE_SIGNING.md index b61bed0..a85eb83 100644 --- a/docs/IMAGE_SIGNING.md +++ b/docs/IMAGE_SIGNING.md @@ -1,286 +1,335 @@ # Image Signing with ATProto -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. +ATCR supports cryptographic signing of container images to ensure authenticity and integrity. Users have two options: -## Background: Why Not Cosign? +1. **Automatic signing (recommended)**: Credential helper signs images automatically on every push +2. **Manual signing**: Use standard Cosign tools yourself -[Sigstore Cosign](https://github.com/sigstore/cosign) is the most popular OCI image signing tool, but has several incompatibilities with ATProto: +Both approaches use the OCI Referrers API bridge for verification with standard tools (Cosign, Notary, Kubernetes admission controllers). -### 1. Key Format Mismatch +## Design Constraints -**ATProto PDS keys:** -- Format: secp256k1 (K256) for signing -- Purpose: ATProto record signatures, DID authentication -- Access: Private keys never leave the PDS server -- Standard: ATProto specification +### Why Server-Side Signing Doesn't Work -**Cosign expected keys:** -- Format: ECDSA P-256, RSA, or Ed25519 -- Purpose: Image signing (not ATProto records) -- Access: User-controlled private keys -- Standard: Sigstore/PKIX +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: -**Problem:** Can't use PDS keys directly for Cosign signing - wrong curve, wrong access model, wrong security boundary. - -### 2. No Direct PDS Key Access - -**Security model:** -- PDS private keys are server-side secrets -- Never exposed to clients (even authenticated users) -- Used only by PDS for ATProto operations -- Exposing them would compromise entire account security - -**Cosign requirement:** -- Needs access to private key for signing operations -- Expects user-controlled keys or KMS integration - -**Problem:** Can't sign images client-side with PDS keys without fundamentally breaking ATProto security model. - -### 3. Keyless Signing Complexity - -Cosign supports "keyless" signing via OIDC + Fulcio CA: - -**What it requires:** -- OIDC identity provider (Google, GitHub, etc.) -- Fulcio certificate authority (issues short-lived certs) -- Rekor transparency log (immutable signature log) -- All infrastructure managed by Sigstore - -**ATProto adaptation would need:** -- **OIDC bridge**: Make ATProto DIDs look like OIDC identities - - Map `did:plc:alice123` → OIDC claims - - PDS as OIDC provider? (not in spec) - - Requires custom OIDC server wrapping ATProto auth -- **Fulcio adaptation**: Issue certs based on ATProto identities - - Deploy and manage CA infrastructure - - Handle DID resolution in cert issuance - - Trust anchor distribution -- **Rekor instance**: Public transparency log for signatures - - High availability requirements - - Storage and indexing at scale - - Replication and backup - -**Problem:** Too much infrastructure for ATCR to host and manage. Defeats the purpose of decentralized architecture. - -### 4. Signature Storage - -**Cosign storage:** -- OCI registry artifacts (signatures as ORAS manifests) -- Stored alongside images in registry - -**ATCR ideal:** -- Signatures in ATProto records (user's PDS) -- Discoverable via ATProto queries -- Integrated with ATProto's existing signature/verification model - -**Problem:** Would need to patch Cosign or run dual storage (OCI + ATProto) which creates consistency issues. - -### Conclusion: Cosign Doesn't Fit - -While Cosign is excellent for traditional registries, forcing it into ATProto would require: -- Breaking ATProto security model (exposing PDS keys), OR -- Building massive OIDC/Fulcio/Rekor infrastructure, OR -- Running parallel storage systems with consistency problems - -**Better approach:** Use a more flexible signing framework designed for extensibility. - -## Notary v2: Plugin-Based Architecture - -[Notary v2](https://notaryproject.dev/) (also called "Notation" or "Notary Project") is a CNCF signature specification with a plugin architecture that fits ATProto better. - -### Why Notary v2? - -**Flexible plugin system:** -- **Trust store plugins**: Custom key resolution (e.g., from ATProto records) -- **Signature plugins**: Custom signature storage (e.g., in PDS) -- **Verification plugins**: Custom verification logic -- Plugins written in any language, communicate via stdio - -**Multiple key types supported:** -- ECDSA, RSA, Ed25519 out of box -- Can support custom key types via plugins -- Signature envelope format is extensible - -**Designed for extensibility:** -- Not tied to specific PKI (unlike Cosign/Sigstore) -- Trust policies are configurable -- Storage backend is pluggable -- Works with custom identity systems - -**Standard CLI:** -- `notation sign` / `notation verify` commands -- Users don't need to learn new tools -- Integration with Docker/containerd - -### Notary v2 Architecture +**The problem: Signing "on behalf of" isn't real signing** ``` -┌─────────────────────┐ -│ notation CLI │ User signs/verifies images -└──────────┬──────────┘ - │ - ├─────────────────────────────────────┐ - │ │ -┌──────────▼─────────┐ ┌───────────▼──────────┐ -│ Signing Plugin │ │ Trust Store Plugin │ -│ │ │ │ -│ - Read private key │ │ - Resolve DID → PDS │ -│ - Generate sig │ │ - Fetch public keys │ -│ - Store in PDS │ │ - Verify trust │ -└────────────────────┘ └──────────────────────┘ - │ │ - ▼ ▼ -┌─────────────────────────────────────────────────────────┐ -│ User's PDS (ATProto) │ -│ │ -│ io.atcr.signing.key (public keys) │ -│ io.atcr.signature (signatures) │ -└─────────────────────────────────────────────────────────┘ +❌ AppView signs image → Proves "AppView vouches for this" +❌ Hold signs image → Proves "Hold vouches for this" +❌ PDS signs image → Proves "PDS vouches for this" +✅ Alice signs image → Proves "Alice created/approved this" ``` -## Proposed Design: ATProto Signing +**Why GitHub can do it:** +- GitHub Actions runs with your GitHub identity +- OIDC token proves "this workflow runs as alice on GitHub" +- Fulcio certificate authority issues cert based on that proof +- Still "alice" signing, just via GitHub's infrastructure + +**Why ATCR can't replicate this:** +- ATProto doesn't have OIDC/Fulcio equivalent +- AppView can't sign "as alice" - only alice can +- No secure server-side storage for user private keys + - ATProto doesn't have encrypted record storage yet + - Storing keys in AppView database = AppView controls keys, not alice +- Hold's PDS has its own private key, but signing with it proves hold ownership, not user ownership + +**Conclusion:** Signing must happen **client-side with user-controlled keys**. + +### Why ATProto Record Signatures Aren't Sufficient + +ATProto already signs all records stored in PDSs. When a manifest is stored as an `io.atcr.manifest` record, it includes: + +```json +{ + "uri": "at://did:plc:alice123/io.atcr.manifest/abc123", + "cid": "bafyrei...", + "value": { /* manifest data */ }, + "sig": "..." // ← PDS signature over record +} +``` + +**What this proves:** +- ✅ Alice's PDS created and signed this record +- ✅ Record hasn't been tampered with since signing +- ✅ CID correctly represents the record content + +**What this doesn't prove:** +- ❌ Alice personally approved this image +- ❌ Alice's private key was involved (only PDS key) + +**The gap:** +- A compromised or malicious PDS could create fake manifest records and sign them validly +- PDS operator could sign manifests without user's knowledge +- No proof that the *user* (not just their PDS) approved the image + +**For true image signing, we need:** +- User-controlled private keys (not PDS keys) +- Client-side signing (where user has key access) +- Separate signature records proving user approval + +**Important nuance - PDS Trust Spectrum:** + +While ATProto records are always signed by the PDS, this doesn't provide user-level signing for image verification: + +1. **Self-hosted PDS with user-controlled keys:** + - User runs their own PDS and controls PDS rotation keys + - PDS signature ≈ user signature (trusted operator) + - Still doesn't work with standard tools (Cosign/Notary) + +2. **Shared/managed PDS (e.g., Bluesky):** + - PDS operated by third party (bsky.social) + - Auto-generated keys controlled by operator + - User doesn't have access to PDS rotation keys + - PDS signature ≠ user signature + +**For ATCR:** +- Credential helper signing works for all users (self-hosted or shared PDS) +- Provides user-controlled keys separate from PDS keys +- Works with standard verification tools via OCI Referrers API bridge + +## Signing Options + +### Option 1: Automatic Signing (Recommended) + +The credential helper automatically signs images on every push - no extra commands needed. + +**How it works:** +- Credential helper runs on every `docker push` for authentication +- Extended to also sign the manifest digest with user's private key +- Private key stored securely in OS keychain +- Signature sent to AppView and stored in ATProto +- Completely transparent to the user + +### Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ docker push atcr.io/alice/myapp:latest │ +└────────────────────┬────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────┐ +│ docker-credential-atcr (runs automatically) │ +│ │ +│ 1. Authenticate to AppView (OAuth) │ +│ 2. Get registry JWT │ +│ 3. Sign manifest digest with local private key ← NEW +│ 4. Send signature to AppView ← NEW +│ │ +│ Private key stored in OS keychain │ +│ (macOS Keychain, Windows Credential Manager, etc.) │ +└────────────────────┬────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────┐ +│ AppView │ +│ │ +│ 1. Receives signature from credential helper │ +│ 2. Stores in user's PDS (io.atcr.signature) │ +│ │ +│ OR stores in hold's PDS for BYOS scenarios │ +└─────────────────────────────────────────────────────┘ +``` + +**User experience:** + +```bash +# One-time setup +docker login atcr.io +# → Credential helper generates ECDSA key pair +# → Private key stored in OS keychain +# → Public key published to user's PDS + +# Every push (automatic signing) +docker push atcr.io/alice/myapp:latest +# → Image pushed +# → Automatically signed by credential helper +# → No extra commands! + +# Verification (standard Cosign) +cosign verify atcr.io/alice/myapp:latest --key alice.pub +``` + +### Option 2: Manual Signing (DIY) + +Use standard Cosign tools yourself if you prefer manual control. + +**How it works:** +- You manage your own signing keys +- You run `cosign sign` manually after pushing +- Signatures stored in ATProto via OCI Referrers API +- Full control over signing workflow + +**User experience:** + +```bash +# Push image +docker push atcr.io/alice/myapp:latest + +# Sign manually with Cosign +cosign sign atcr.io/alice/myapp:latest --key cosign.key + +# Cosign stores signature via registry's OCI API +# AppView receives signature and stores in ATProto + +# Verification (same as automatic) +cosign verify atcr.io/alice/myapp:latest --key cosign.pub +``` + +**When to use:** +- Need specific signing workflows (e.g., CI/CD pipelines) +- Want to use hardware tokens (YubiKey) +- Prefer manual control over automatic signing +- Already using Cosign in your organization ### Key Management -**Separate signing keys from PDS keys:** +**Key generation (first run):** +1. Credential helper checks for existing signing key in OS keychain +2. If not found, generates new ECDSA P-256 key pair (or Ed25519) +3. Stores private key in OS keychain with access control +4. Derives public key for publishing -1. **User generates signing key pair locally:** - ```bash - notation key generate --id alice-signing-key --type ecdsa - # Or: --type ed25519, --type rsa - ``` +**Public key publishing:** +```json +{ + "$type": "io.atcr.signing.key", + "keyId": "credential-helper-default", + "keyType": "ecdsa-p256", + "publicKey": "-----BEGIN PUBLIC KEY-----\nMFkw...", + "validFrom": "2025-10-20T12:00:00Z", + "expiresAt": null, + "revoked": false, + "purpose": ["image-signing"], + "deviceId": "alice-macbook-pro", + "createdAt": "2025-10-20T12:00:00Z" +} +``` -2. **Public key published to ATProto:** - ```json - { - "$type": "io.atcr.signing.key", - "keyId": "alice-signing-key", - "keyType": "ecdsa-p256", - "publicKey": "-----BEGIN PUBLIC KEY-----\nMFkw...", - "validFrom": "2025-10-20T12:00:00Z", - "expiresAt": "2026-10-20T12:00:00Z", - "revoked": false, - "createdAt": "2025-10-20T12:00:00Z" - } - ``` +**Record stored in:** User's PDS at `io.atcr.signing.key/credential-helper-default` -3. **Private key stored locally:** - - Docker credential store - - OS keychain (macOS Keychain, Windows Credential Manager) - - File with restrictive permissions - - Hardware security module (future) - -**Why separate keys?** -- ✅ No need to access PDS private keys -- ✅ Standard key formats (ECDSA, Ed25519, RSA) -- ✅ User controls key lifecycle -- ✅ Can use hardware tokens (YubiKey, etc.) -- ✅ Security boundary separation (signing ≠ identity) -- ✅ Key rotation without changing DID +**Key storage locations:** +- **macOS:** Keychain Access (secure enclave on modern Macs) +- **Windows:** Credential Manager / Windows Data Protection API +- **Linux:** Secret Service API (gnome-keyring, kwallet) +- **Fallback:** Encrypted file with restrictive permissions (0600) ### Signing Flow ``` -1. User: notation sign atcr.io/alice/myapp:latest --key alice-signing-key - -2. notation-atproto plugin: - a. Resolve image → manifest digest - b. Read private key from local keystore - c. Generate signature over manifest digest - d. Get OAuth token for alice's PDS - e. Create signature record in alice's PDS - -3. Signature stored in alice's PDS: +1. docker push atcr.io/alice/myapp:latest + ↓ +2. Docker daemon calls credential helper: + docker-credential-atcr get atcr.io + ↓ +3. Credential helper flow: + a. Authenticate via OAuth (existing) + b. Receive registry JWT from AppView (existing) + c. Fetch manifest digest from registry (NEW) + d. Load private key from OS keychain (NEW) + e. Sign manifest digest (NEW) + f. Send signature to AppView via XRPC (NEW) + ↓ +4. AppView stores signature: { "$type": "io.atcr.signature", "repository": "alice/myapp", "digest": "sha256:abc123...", - "signature": "MEUCIQDx...", // base64 signature bytes - "keyId": "alice-signing-key", + "signature": "MEUCIQDx...", + "keyId": "credential-helper-default", "signatureAlgorithm": "ecdsa-p256-sha256", "signedAt": "2025-10-20T12:34:56Z" } - -4. Record key: sha256 of (digest + keyId) for deduplication + ↓ +5. Return registry JWT to Docker + ↓ +6. Docker proceeds with push ``` -### Verification Flow +### Signature Storage -``` -1. User: notation verify atcr.io/alice/myapp:latest +**Option 1: User's PDS (Default)** +- Signature stored in alice's PDS +- Collection: `io.atcr.signature` +- Discoverable via alice's ATProto repo +- User owns all signing metadata -2. notation-atproto plugin: - a. Resolve "alice" → did:plc:alice123 → pds.alice.com - b. Fetch manifest digest: sha256:abc123 - c. Query alice's PDS for signatures: - GET /xrpc/com.atproto.repo.listRecords? - repo=did:plc:alice123& - collection=io.atcr.signature - d. Filter records matching digest: sha256:abc123 - e. For each signature: - - Fetch public key from io.atcr.signing.key record - - Check key not revoked, not expired - - Verify signature bytes over digest - - Check trust policy (is this key trusted?) +**Option 2: Hold's PDS (BYOS)** +- Signature stored in hold's embedded PDS +- Useful for shared holds with multiple users +- Hold acts as signature repository +- Parallel to SBOM storage model -3. Trust policy evaluation: - - Signature valid cryptographically? ✓ - - Key belongs to image owner (alice)? ✓ - - Key not revoked? ✓ - - Key not expired? ✓ - - Trust policy satisfied? ✓ - -4. Output: Verification succeeded ✓ -``` - -### Trust Policies - -Notary v2 uses trust policies to define what signatures are required: - -```json -{ - "version": "1.0", - "trustPolicies": [ - { - "name": "atcr-images", - "registryScopes": ["atcr.io/*/*"], - "signatureVerification": { - "level": "strict" - }, - "trustStores": ["atproto:default"], - "trustedIdentities": [ - "did:plc:*" // Trust any ATProto DID - ] - } - ] +**Decision logic:** +```go +// In AppView signature handler +if manifest.HoldDid != "" && manifest.HoldDid != appview.DefaultHoldDid { + // BYOS scenario - store in hold's PDS + storeSignatureInHold(manifest.HoldDid, signature) +} else { + // Default - store in user's PDS + storeSignatureInUserPDS(userDid, signature) } ``` -**Policy options:** -- `level: strict` - Signature required, verification must pass -- `level: permissive` - Signature optional, but verified if present -- `level: audit` - Signature logged but doesn't block -- `level: skip` - No verification +## Signature Format -**Trust store resolution:** -- `atproto:default` - Use ATProto plugin to resolve keys -- Plugin queries user's PDS for `io.atcr.signing.key` records -- Verifies key is owned by the image owner (DID match) +Signatures are stored in a simple format in ATProto and transformed to Cosign-compatible format when served via the OCI Referrers API: -### ATProto Records +**ATProto storage format:** +```json +{ + "$type": "io.atcr.signature", + "repository": "alice/myapp", + "digest": "sha256:abc123...", + "signature": "base64-encoded-signature-bytes", + "keyId": "credential-helper-default", + "signatureAlgorithm": "ecdsa-p256-sha256", + "signedAt": "2025-10-20T12:34:56Z", + "format": "simple" +} +``` -**io.atcr.signing.key** - Public signing keys +**OCI Referrers format (served by AppView):** +```json +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [{ + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:...", + "artifactType": "application/vnd.dev.cosign.simplesigning.v1+json", + "annotations": { + "dev.sigstore.cosign.signature": "MEUCIQDx...", + "io.atcr.keyId": "credential-helper-default", + "io.atcr.signedAt": "2025-10-20T12:34:56Z" + } + }] +} +``` + +This allows: +- Simple storage in ATProto +- Compatible with Cosign verification +- No duplicate storage needed + +## ATProto Records + +### io.atcr.signing.key - Public Signing Keys ```json { "$type": "io.atcr.signing.key", - "keyId": "alice-signing-key", + "keyId": "credential-helper-default", "keyType": "ecdsa-p256", "publicKey": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZI...", "validFrom": "2025-10-20T12:00:00Z", "expiresAt": "2026-10-20T12:00:00Z", "revoked": false, "purpose": ["image-signing"], + "deviceId": "alice-macbook-pro", + "comment": "Generated by docker-credential-atcr", "createdAt": "2025-10-20T12:00:00Z" } ``` @@ -288,15 +337,17 @@ Notary v2 uses trust policies to define what signatures are required: **Record key:** `keyId` (user-chosen identifier) **Fields:** -- `keyId`: Unique identifier for this key +- `keyId`: Unique identifier (e.g., `credential-helper-default`, `ci-key-1`) - `keyType`: Algorithm (ecdsa-p256, ed25519, rsa-2048, rsa-4096) - `publicKey`: PEM-encoded public key - `validFrom`: Key becomes valid at this time -- `expiresAt`: Key expires at this time (null = no expiry) -- `revoked`: Key has been revoked (true/false) -- `purpose`: Array of purposes (image-signing, sbom-signing, etc.) +- `expiresAt`: Key expires (null = no expiry) +- `revoked`: Revocation status +- `purpose`: Key purposes (image-signing, sbom-signing, etc.) +- `deviceId`: Optional device identifier +- `comment`: Optional human-readable comment -**io.atcr.signature** - Image signatures +### io.atcr.signature - Image Signatures ```json { @@ -304,9 +355,10 @@ Notary v2 uses trust policies to define what signatures are required: "repository": "alice/myapp", "digest": "sha256:abc123...", "signature": "MEUCIQDxH7...", - "keyId": "alice-signing-key", + "keyId": "credential-helper-default", "signatureAlgorithm": "ecdsa-p256-sha256", "signedAt": "2025-10-20T12:34:56Z", + "format": "simple", "createdAt": "2025-10-20T12:34:56Z" } ``` @@ -315,419 +367,591 @@ Notary v2 uses trust policies to define what signatures are required: **Fields:** - `repository`: Image repository (alice/myapp) -- `digest`: Manifest digest being signed +- `digest`: Manifest digest being signed (sha256:...) - `signature`: Base64-encoded signature bytes - `keyId`: Reference to signing key record -- `signatureAlgorithm`: Algorithm used for signing -- `signedAt`: When signature was created +- `signatureAlgorithm`: Algorithm used +- `signedAt`: Timestamp of signature creation +- `format`: Signature format (simple, cosign, notary) -### Plugin Implementation +## Verification -**notation-atproto** - Notary v2 plugin for ATProto +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. + +### Integration with Docker/Kubernetes Workflows + +**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. + +**Reality check:** +- Cosign looks for signatures as OCI referrers or attached manifests +- Notary looks for signatures in registry's `_notary` endpoint +- Kubernetes admission controllers (Sigstore Policy Controller, Ratify) use these tools +- They won't find signatures stored only in ATProto + +**The solution:** AppView implements the **OCI Referrers API** and serves ATProto signatures as OCI artifacts on-demand. + +### How It Works: OCI Referrers API Bridge + +When Cosign/Notary verify an image, they call the OCI Referrers API: + +``` +cosign verify atcr.io/alice/myapp:latest + ↓ +GET /v2/alice/myapp/referrers/sha256:abc123 + ↓ +AppView: + 1. Queries alice's PDS for io.atcr.signature records + 2. Filters signatures matching digest sha256:abc123 + 3. Transforms to OCI referrers format + 4. Returns as JSON + ↓ +Cosign receives OCI referrer manifest + ↓ +Verifies signature (works normally) +``` + +**AppView endpoint implementation:** -**Trust store plugin:** ```go -// Implements: notation trust store plugin spec -// https://notaryproject.dev/docs/user-guides/how-to/plugin-management/ +// GET /v2/{owner}/{repo}/referrers/{digest} +func (h *Handler) GetReferrers(w http.ResponseWriter, r *http.Request) { + owner := mux.Vars(r)["owner"] + digest := mux.Vars(r)["digest"] -type ATProtoTrustStore struct { - resolver *atproto.Resolver - client *atproto.Client -} + // 1. Resolve owner → DID → PDS + did, pds, err := h.resolver.ResolveIdentity(owner) -// GetKeys resolves public keys for a given identity (DID) -func (t *ATProtoTrustStore) GetKeys(did string) ([]PublicKey, error) { - // 1. Resolve DID → PDS endpoint - pds, err := t.resolver.ResolvePDS(did) + // 2. Query PDS for signatures matching digest + signatures, err := h.atproto.ListRecords(pds, did, "io.atcr.signature") + filtered := filterByDigest(signatures, digest) - // 2. Query PDS for io.atcr.signing.key records - records, err := t.client.ListRecords(pds, did, "io.atcr.signing.key") - - // 3. Filter active keys (not revoked, not expired) - keys := []PublicKey{} - for _, record := range records { - if !record.Revoked && !record.Expired() { - keys = append(keys, ParsePublicKey(record.PublicKey)) - } + // 3. Transform to OCI Index format + index := &ocispec.Index{ + SchemaVersion: 2, + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{}, } - return keys, nil + for _, sig := range filtered { + index.Manifests = append(index.Manifests, ocispec.Descriptor{ + MediaType: "application/vnd.oci.image.manifest.v1+json", + Digest: sig.Digest, + Size: sig.Size, + ArtifactType: "application/vnd.dev.cosign.simplesigning.v1+json", + Annotations: map[string]string{ + "dev.sigstore.cosign.signature": sig.Signature, + "io.atcr.keyId": sig.KeyId, + "io.atcr.signedAt": sig.SignedAt, + "io.atcr.source": fmt.Sprintf("at://%s/io.atcr.signature/%s", did, sig.Rkey), + }, + }) + } + + // 4. Return as JSON + w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) + json.NewEncoder(w).Encode(index) } ``` -**Signature store plugin:** +**Benefits:** +- ✅ **No dual storage** - signatures only in ATProto +- ✅ **Standard tools work** - Cosign, Notary, Kubernetes admission controllers +- ✅ **Single source of truth** - ATProto PDS +- ✅ **On-demand transformation** - only when needed +- ✅ **Offline verification** - can cache public keys + +**Trade-offs:** +- ⚠️ AppView must be reachable during verification (but already required for image pulls) +- ⚠️ Transformation overhead (minimal - just JSON formatting) + +### Alternative Approaches + +#### Option 1: Dual Storage (Not Recommended) + +Store signatures in BOTH ATProto AND OCI registry: + ```go -// Store signature in user's PDS -func (s *ATProtoSignatureStore) StoreSignature(sig Signature) error { - // 1. Get OAuth token for user's PDS - token, err := s.oauthClient.GetToken() +// In credential helper or AppView +func StoreSignature(sig Signature) error { + // 1. Store in ATProto (user's PDS or hold's PDS) + err := storeInATProto(sig) - // 2. Create signature record - record := SignatureRecord{ - Type: "io.atcr.signature", - Repository: sig.Repository, - Digest: sig.Digest, - Signature: base64.Encode(sig.Bytes), - KeyId: sig.KeyId, - SignatureAlgorithm: sig.Algorithm, - SignedAt: time.Now(), - } - - // 3. Generate record key (hash of digest + keyId) - rkey := sha256.Sum256([]byte(sig.Digest + sig.KeyId)) - - // 4. Write to PDS - err = s.client.PutRecord(pds, did, "io.atcr.signature", hex.Encode(rkey), record) + // 2. ALSO store as OCI artifact in registry + err = storeAsOCIReferrer(sig) return err } - -// Retrieve signatures for a digest -func (s *ATProtoSignatureStore) GetSignatures(did, digest string) ([]Signature, error) { - // Query PDS for matching signatures - records, err := s.client.ListRecords(pds, did, "io.atcr.signature") - - // Filter by digest - sigs := []Signature{} - for _, record := range records { - if record.Digest == digest { - sigs = append(sigs, ParseSignature(record)) - } - } - - return sigs, nil -} ``` -**Plugin installation:** -```bash -# Install notation CLI -brew install notation - -# Install ATProto plugin -notation plugin install notation-atproto --version v1.0.0 - -# Configure trust policy -cat > ~/.config/notation/trustpolicy.json < -notation-atproto signature inspect +```yaml +# admission-controller deployment +apiVersion: v1 +kind: ConfigMap +metadata: + name: atcr-policy +data: + policy.yaml: | + policies: + - name: require-atcr-signatures + images: + - "atcr.io/*/*" + verification: + method: atproto + requireSignature: true ``` -**Helper utilities:** -- Bulk re-signing for key rotation +**Benefits:** +- ✅ Native ATProto support +- ✅ No OCI conversion needed +- ✅ Can enforce ATCR-specific policies + +**Trade-offs:** +- ❌ Doesn't work with standard tools (Cosign, Notary) +- ❌ Additional infrastructure to maintain +- ❌ Limited ecosystem integration + +#### Recommendation + +**Primary approach: OCI Referrers API Bridge** +- Implement `/v2/{owner}/{repo}/referrers/{digest}` in AppView +- Query ATProto on-demand and transform to OCI format +- Works with Cosign, Notary, Kubernetes admission controllers +- No duplicate storage, single source of truth + +**Why this works:** +- Cosign/Notary just make HTTP GET requests to the registry +- AppView is already the registry - just add one endpoint +- Transformation is simple (ATProto record → OCI descriptor) +- Signatures stay in ATProto where they belong + +### Cosign Verification (OCI Referrers API) + +```bash +# Standard Cosign works out of the box: +cosign verify atcr.io/alice/myapp:latest \ + --key <(atcr-cli key export alice credential-helper-default) + +# What happens: +# 1. Cosign queries: GET /v2/alice/myapp/referrers/sha256:abc123 +# 2. AppView fetches signatures from alice's PDS +# 3. AppView returns OCI referrers index +# 4. Cosign downloads signature artifact +# 5. Cosign verifies with public key +# 6. Success! + +# Or with public key inline: +cosign verify atcr.io/alice/myapp:latest --key '-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZI... +-----END PUBLIC KEY-----' +``` + +**Fetching public keys from ATProto:** + +Public keys are stored in ATProto records and can be fetched via standard XRPC: + +```bash +# Query for public keys +curl "https://atcr.io/xrpc/com.atproto.repo.listRecords?\ + repo=did:plc:alice123&\ + collection=io.atcr.signing.key" + +# Extract public key and save as PEM +# Then use in Cosign: +cosign verify atcr.io/alice/myapp:latest --key alice.pub +``` + +### Kubernetes Policy Example (OCI Referrers API) + +```yaml +# Sigstore Policy Controller +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: atcr-images-must-be-signed +spec: + images: + - glob: "atcr.io/*/*" + authorities: + - key: + # Public key from ATProto record + data: | + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZI... + -----END PUBLIC KEY----- +``` + +**How it works:** +1. Pod tries to run `atcr.io/alice/myapp:latest` +2. Policy Controller intercepts +3. Queries registry for OCI referrers (finds signature) +4. Verifies signature with public key +5. Allows pod if valid + +### Trust Policies + +Define what signatures are required for image execution: + +```yaml +# ~/.atcr/trust-policy.yaml +policies: + - name: production-images + scope: "atcr.io/alice/prod-*" + require: + - signature: true + - keyIds: ["ci-key-1", "alice-release-key"] + action: enforce # block, audit, or allow + + - name: dev-images + scope: "atcr.io/alice/dev-*" + require: + - signature: false + action: audit +``` + +**Integration points:** +- Kubernetes admission controller +- Docker Content Trust equivalent +- CI/CD pipeline gates + +## Security Considerations + +### Key Storage Security + +**OS keychain benefits:** +- ✅ Encrypted storage +- ✅ Access control (requires user password/biometric) +- ✅ Auditing (macOS logs keychain access) +- ✅ Hardware-backed on modern systems (Secure Enclave, TPM) + +**Best practices:** +- Generate keys on device (never transmitted) +- Use hardware-backed storage when available +- Require user approval for key access (biometric/password) +- Rotate keys periodically (e.g., annually) + +### Trust Model + +**What signatures prove:** +- ✅ User had access to private key at signing time +- ✅ Manifest digest matches what was signed +- ✅ Signature created by specific key ID +- ✅ Timestamp of signature creation + +**What signatures don't prove:** +- ❌ Image is free of vulnerabilities +- ❌ Image contents are safe to run +- ❌ User's identity is verified (depends on DID trust) +- ❌ Private key wasn't compromised + +**Trust dependencies:** +- User protects their private key +- OS keychain security +- DID resolution accuracy (PLC directory, did:web) +- PDS serves correct public key records +- Signature algorithms remain secure + +### Multi-Device Support + +**Challenge:** User has multiple devices (laptop, desktop, CI/CD) + +**Options:** + +1. **Separate keys per device:** + ```json + { + "keyId": "alice-macbook-pro", + "deviceId": "macbook-pro" + }, + { + "keyId": "alice-desktop", + "deviceId": "desktop" + } + ``` + - Pros: Best security (key compromise limited to one device) + - Cons: Need to trust signatures from any device + +2. **Shared key via secure sync:** + - Export key from primary device + - Import to secondary devices + - Stored in each device's keychain + - Pros: Single key ID to trust + - Cons: More attack surface (key on multiple devices) + +3. **Primary + secondary model:** + - Primary key on main device + - Secondary keys on other devices + - Trust policy requires primary key signature + - Pros: Flexible + secure + - Cons: More complex setup + +**Recommendation:** Separate keys per device (Option 1) for security, with trust policy accepting any of user's keys. + +### Key Compromise Response + +If a device is lost or private key is compromised: + +1. **Revoke the key** via AppView web UI or XRPC API + - Updates `io.atcr.signing.key` record: `"revoked": true` + - Revocation is atomic and immediate + +2. **Generate new key** on new/existing device + - Automatic on next `docker login` from secure device + - Credential helper generates new key pair + +3. **Old signatures still exist but fail verification** + - Revoked key = untrusted + - No certificate revocation list (CRL) delays + - Globally visible within seconds + +### CI/CD Signing + +For automated builds, use standard Cosign in your CI pipeline: + +```yaml +# .github/workflows/build.yml +steps: + - name: Push image + run: docker push atcr.io/alice/myapp:latest + + - name: Sign with Cosign + run: cosign sign atcr.io/alice/myapp:latest --key ${{ secrets.COSIGN_KEY }} +``` + +**Key management:** +- Generate Cosign key pair: `cosign generate-key-pair` +- Store private key in CI secrets (GitHub Actions, GitLab CI, etc.) +- Publish public key to PDS via XRPC or AppView web UI +- Cosign stores signature via registry's OCI API +- AppView automatically stores in ATProto + +**Or use automatic signing:** +- Configure credential helper in CI environment +- Signatures happen automatically on push +- No explicit signing step needed + +## Implementation Roadmap + +### Phase 1: Core Signing (2-3 weeks) + +**Week 1: Credential helper key management** +- Generate ECDSA key pair on first run +- Store private key in OS keychain +- Create `io.atcr.signing.key` record in PDS +- Handle key rotation + +**Week 2: Signing integration** +- Sign manifest digest after authentication +- Send signature to AppView via XRPC +- AppView stores in user's PDS or hold's PDS +- Error handling and retries + +**Week 3: OCI Referrers API** +- Implement `GET /v2/{owner}/{repo}/referrers/{digest}` in AppView +- Query ATProto for signatures +- Transform to OCI Index format +- Return Cosign-compatible artifacts +- Test with `cosign verify` + +### Phase 2: Enhanced Features (2-3 weeks) + +**Key management (credential helper):** +- Key rotation support +- Revocation handling +- Device identification +- Key expiration + +**Signature storage:** +- Handle manual Cosign signing (via OCI API) +- Store signatures from both automatic and manual flows +- Signature deduplication - Signature audit logs -- Trust policy generators -- Key lifecycle management -### Phase 3: AppView Integration (2-3 weeks) +**AppView endpoints:** +- XRPC endpoints for key/signature queries +- Web UI for viewing keys and signatures +- Key revocation via web interface -**Web UI features:** -- Display signature status on repository pages -- Show signing keys for users +### Phase 3: Kubernetes Integration (2-3 weeks) + +**Admission controller setup:** +- Documentation for Sigstore Policy Controller +- Example policies for ATCR images +- Public key management (fetch from ATProto) +- Integration testing with real clusters + +**Advanced features:** +- Signature caching in AppView (reduce PDS queries) +- Multi-signature support (require N signatures) +- Timestamp verification +- Signature expiration policies + +### Phase 4: UI Integration (1-2 weeks) + +**AppView web UI:** +- Show signature status on repository pages +- List signing keys for users +- Revoke keys via web interface - Signature verification badges -- Key management interface -**API endpoints:** -- `GET /v2/alice/myapp/signatures` - List signatures for image -- `GET /v2/alice/keys` - List user's signing keys -- `POST /v2/alice/keys/revoke` - Revoke key via web UI +## Comparison: Automatic vs Manual Signing -### Phase 4: Advanced Features (ongoing) +| Feature | Automatic (Credential Helper) | Manual (Standard Cosign) | +|---------|-------------------------------|--------------------------| +| **User action** | Zero - happens on push | `cosign sign` after push | +| **Key management** | Automatic generation/storage | User manages keys | +| **Consistency** | Every image signed | Easy to forget | +| **Setup** | Works with credential helper | Install Cosign, generate keys | +| **CI/CD** | Automatic if cred helper configured | Explicit signing step | +| **Flexibility** | Opinionated defaults | Full control over workflow | +| **Use case** | Most users, simple workflows | Advanced users, custom workflows | -**Hardware token support:** -- YubiKey integration -- TPM-backed keys -- Hardware-backed keystores +**Recommendation:** +- **Start with automatic**: Best UX, works for most users +- **Use manual** for: CI/CD pipelines, hardware tokens, custom signing workflows -**Timestamp verification:** -- Trusted timestamp authorities -- Prove signature was created at specific time -- Long-term signature validity +## Complete Workflow Summary -**SBOM signing:** -- Sign SBOMs with same keys -- Link SBOM signatures to image signatures -- Unified verification workflow +### Option 1: Automatic Signing (Recommended) -## Comparison: Cosign vs Notary v2 for ATCR +```bash +# Setup (one time) +docker login atcr.io +# → Credential helper generates ECDSA key pair +# → Private key in OS keychain +# → Public key published to PDS -| Feature | Cosign | Notary v2 | Winner | -|---------|--------|-----------|--------| -| **ATProto integration** | Requires OIDC bridge | Plugin system | ✅ Notary | -| **Key format flexibility** | Limited | Extensible | ✅ Notary | -| **Custom storage** | OCI only | Pluggable | ✅ Notary | -| **Infrastructure needs** | Fulcio + Rekor | None | ✅ Notary | -| **Keyless signing** | Yes (complex) | No | ⚠️ Cosign* | -| **Ecosystem maturity** | High | Medium | ⚠️ Cosign* | -| **CLI simplicity** | Very simple | Simple | ⚠️ Cosign* | -| **Plugin development** | N/A | Required | ⚠️ Mixed | +# Push (automatic signing) +docker push atcr.io/alice/myapp:latest +# → Image pushed and signed automatically +# → No extra commands! -*Cosign advantages don't outweigh ATProto incompatibilities +# Verify (standard Cosign) +cosign verify atcr.io/alice/myapp:latest --key alice.pub +# → Cosign queries OCI Referrers API +# → AppView returns ATProto signatures as OCI artifacts +# → Verification succeeds ✓ +``` -**Recommendation: Notary v2 with ATProto plugin** +### Option 2: Manual Signing (DIY) + +```bash +# Push image +docker push atcr.io/alice/myapp:latest + +# Sign with Cosign +cosign sign atcr.io/alice/myapp:latest --key cosign.key +# → Cosign stores via OCI API +# → AppView stores in ATProto + +# Verify (same as automatic) +cosign verify atcr.io/alice/myapp:latest --key cosign.pub +``` + +### Kubernetes (Standard Admission Controller) + +```yaml +# Sigstore Policy Controller (standard) +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: atcr-signed-only +spec: + images: + - glob: "atcr.io/*/*" + authorities: + - key: + data: | + -----BEGIN PUBLIC KEY----- + [Alice's public key from ATProto] + -----END PUBLIC KEY----- +``` + +**How admission control works:** +1. Pod tries to start with `atcr.io/alice/myapp:latest` +2. Policy Controller intercepts +3. Calls `GET /v2/alice/myapp/referrers/sha256:abc123` +4. AppView returns signatures from ATProto +5. Policy Controller verifies with public key +6. Pod allowed to start ✓ + +### Key Design Points + +**User experience:** +- ✅ Two options: automatic (credential helper) or manual (standard Cosign) +- ✅ Standard verification tools work (Cosign, Notary, Kubernetes) +- ✅ No custom ATCR-specific signing commands +- ✅ User-controlled keys (OS keychain or self-managed) + +**Architecture:** +- **Signing**: Client-side only (credential helper or Cosign) +- **Storage**: ATProto (user's PDS or hold's PDS via `io.atcr.signature`) +- **Verification**: Standard tools via OCI Referrers API bridge +- **Bridge**: AppView transforms ATProto → OCI format on-demand + +**Why this works:** +- ✅ No server-side signing needed (impossible with ATProto constraints) +- ✅ Signatures discoverable via ATProto +- ✅ No duplicate storage (single source of truth) +- ✅ Standard OCI compliance for verification ## References +### Signing & Verification +- [Sigstore Cosign](https://github.com/sigstore/cosign) - [Notary v2 Specification](https://notaryproject.dev/) -- [Notation CLI](https://github.com/notaryproject/notation) -- [Notary Plugin Specification](https://notaryproject.dev/docs/user-guides/how-to/plugin-management/) -- [Sigstore Cosign](https://github.com/sigstore/cosign) (for comparison) -- [ATProto Specification](https://atproto.com/) +- [Cosign Signature Specification](https://github.com/sigstore/cosign/blob/main/specs/SIGNATURE_SPEC.md) + +### OCI & Registry +- [OCI Distribution Specification](https://github.com/opencontainers/distribution-spec) +- [OCI Referrers API](https://github.com/oras-project/artifacts-spec/blob/main/manifest-referrers-api.md) - [OCI Artifacts](https://github.com/opencontainers/artifacts) -- [RFC 7515 - JSON Web Signature](https://datatracker.ietf.org/doc/html/rfc7515) (signature formats) + +### ATProto +- [ATProto Specification](https://atproto.com/) +- [ATProto Repository Specification](https://atproto.com/specs/repository) + +### Key Management +- [Docker Credential Helpers](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers) +- [macOS Keychain Services](https://developer.apple.com/documentation/security/keychain_services) +- [Windows Credential Manager](https://docs.microsoft.com/en-us/windows/security/identity-protection/credential-guard/) +- [Linux Secret Service API](https://specifications.freedesktop.org/secret-service/) + +### Kubernetes Integration +- [Sigstore Policy Controller](https://docs.sigstore.dev/policy-controller/overview/) +- [Ratify (Notary verification for Kubernetes)](https://ratify.dev/) diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index 80c2799..175dbf7 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -309,6 +309,49 @@ func GetUserRepositories(db *sql.DB, did string) ([]Repository, error) { return repos, nil } +// GetRepositoryMetadata retrieves metadata for a repository from its most recent manifest +func GetRepositoryMetadata(db *sql.DB, did string, repository string) (title, description, sourceURL, documentationURL, licenses, iconURL string, err error) { + var titleNull, descriptionNull, sourceURLNull, documentationURLNull, licensesNull, iconURLNull sql.NullString + + err = db.QueryRow(` + SELECT title, description, source_url, documentation_url, licenses, icon_url + FROM manifests + WHERE did = ? AND repository = ? + ORDER BY created_at DESC + LIMIT 1 + `, did, repository).Scan(&titleNull, &descriptionNull, &sourceURLNull, &documentationURLNull, &licensesNull, &iconURLNull) + + if err == sql.ErrNoRows { + // No manifests found - return empty strings + return "", "", "", "", "", "", nil + } + if err != nil { + return "", "", "", "", "", "", err + } + + // Convert NullString to string + if titleNull.Valid { + title = titleNull.String + } + if descriptionNull.Valid { + description = descriptionNull.String + } + if sourceURLNull.Valid { + sourceURL = sourceURLNull.String + } + if documentationURLNull.Valid { + documentationURL = documentationURLNull.String + } + if licensesNull.Valid { + licenses = licensesNull.String + } + if iconURLNull.Valid { + iconURL = iconURLNull.String + } + + return title, description, sourceURL, documentationURL, licenses, iconURL, nil +} + // GetUserByDID retrieves a user by DID func GetUserByDID(db *sql.DB, did string) (*User, error) { var user User diff --git a/pkg/appview/db/queries_test.go b/pkg/appview/db/queries_test.go new file mode 100644 index 0000000..14a6793 --- /dev/null +++ b/pkg/appview/db/queries_test.go @@ -0,0 +1,120 @@ +package db + +import ( + "testing" + "time" +) + +func TestGetRepositoryMetadata(t *testing.T) { + // Create in-memory test database + db, err := InitDB(":memory:") + if err != nil { + t.Fatalf("Failed to init database: %v", err) + } + defer db.Close() + + // Insert test user + testUser := &User{ + DID: "did:plc:test123", + Handle: "testuser.bsky.social", + PDSEndpoint: "https://test.pds.example.com", + Avatar: "", + LastSeen: time.Now(), + } + if err := UpsertUser(db, testUser); err != nil { + t.Fatalf("Failed to insert user: %v", err) + } + + // Test 1: No manifests - should return empty strings + title, description, sourceURL, documentationURL, licenses, iconURL, err := GetRepositoryMetadata(db, testUser.DID, "nonexistent") + if err != nil { + t.Fatalf("Expected no error for nonexistent repo, got: %v", err) + } + if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" { + t.Error("Expected all empty strings for nonexistent repository") + } + + // Test 2: Insert manifest with metadata + _, err = db.Exec(` + INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at, + title, description, source_url, documentation_url, licenses, icon_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, testUser.DID, "myapp", "sha256:abc123", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json", + time.Now().Add(-2*time.Hour), + "My App", "A cool application", "https://github.com/user/myapp", "https://docs.example.com", "MIT", "https://example.com/icon.png") + if err != nil { + t.Fatalf("Failed to insert manifest: %v", err) + } + + // Test 3: Retrieve metadata + title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp") + if err != nil { + t.Fatalf("Failed to get repository metadata: %v", err) + } + + if title != "My App" { + t.Errorf("Expected title 'My App', got '%s'", title) + } + if description != "A cool application" { + t.Errorf("Expected description 'A cool application', got '%s'", description) + } + if sourceURL != "https://github.com/user/myapp" { + t.Errorf("Expected sourceURL 'https://github.com/user/myapp', got '%s'", sourceURL) + } + if documentationURL != "https://docs.example.com" { + t.Errorf("Expected documentationURL 'https://docs.example.com', got '%s'", documentationURL) + } + if licenses != "MIT" { + t.Errorf("Expected licenses 'MIT', got '%s'", licenses) + } + if iconURL != "https://example.com/icon.png" { + t.Errorf("Expected iconURL 'https://example.com/icon.png', got '%s'", iconURL) + } + + // Test 4: Insert newer manifest with different metadata + _, err = db.Exec(` + INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at, + title, description, source_url, documentation_url, licenses, icon_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, testUser.DID, "myapp", "sha256:def456", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json", + time.Now(), // Most recent + "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") + if err != nil { + t.Fatalf("Failed to insert newer manifest: %v", err) + } + + // Test 5: Should return metadata from most recent manifest + title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp") + if err != nil { + t.Fatalf("Failed to get repository metadata: %v", err) + } + + if title != "My App v2" { + t.Errorf("Expected title from newest manifest 'My App v2', got '%s'", title) + } + if description != "An even cooler application" { + t.Errorf("Expected description from newest manifest, got '%s'", description) + } + if licenses != "Apache-2.0" { + t.Errorf("Expected licenses 'Apache-2.0', got '%s'", licenses) + } + + // Test 6: Manifest with NULL metadata fields + _, err = db.Exec(` + INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, testUser.DID, "minimal-app", "sha256:minimal", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json", time.Now()) + if err != nil { + t.Fatalf("Failed to insert minimal manifest: %v", err) + } + + // Test 7: Should handle NULL fields gracefully + title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "minimal-app") + if err != nil { + t.Fatalf("Failed to get repository metadata for minimal app: %v", err) + } + + if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" { + t.Error("Expected all empty strings for manifest with NULL metadata fields") + } +} diff --git a/pkg/appview/handlers/repository.go b/pkg/appview/handlers/repository.go index f2e932d..168b6e5 100644 --- a/pkg/appview/handlers/repository.go +++ b/pkg/appview/handlers/repository.go @@ -134,6 +134,20 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request ManifestCount: len(manifests), } + // Fetch repository metadata from most recent manifest + title, description, sourceURL, documentationURL, licenses, iconURL, err := db.GetRepositoryMetadata(h.DB, owner.DID, repository) + if err != nil { + log.Printf("Failed to fetch repository metadata: %v", err) + // Continue without metadata on error + } else { + repo.Title = title + repo.Description = description + repo.SourceURL = sourceURL + repo.DocumentationURL = documentationURL + repo.Licenses = licenses + repo.IconURL = iconURL + } + // Fetch star count stats, err := db.GetRepositoryStats(h.DB, owner.DID, repository) if err != nil { diff --git a/pkg/appview/holdhealth/checker_test.go b/pkg/appview/holdhealth/checker_test.go index cb4cd6c..21f97b2 100644 --- a/pkg/appview/holdhealth/checker_test.go +++ b/pkg/appview/holdhealth/checker_test.go @@ -251,3 +251,20 @@ func TestGetCacheStats(t *testing.T) { t.Errorf("Expected unreachable=1, got %v", stats["unreachable"]) } } + +func TestNewWorkerWithStartupDelay(t *testing.T) { + checker := NewChecker(15 * time.Minute) + + // Test NewWorker (no delay) + worker := NewWorker(checker, nil, 5*time.Minute) + if worker.startupDelay != 0 { + t.Errorf("Expected startupDelay=0 for NewWorker, got %v", worker.startupDelay) + } + + // Test NewWorkerWithStartupDelay + startupDelay := 5 * time.Second + workerWithDelay := NewWorkerWithStartupDelay(checker, nil, 5*time.Minute, startupDelay) + if workerWithDelay.startupDelay != startupDelay { + t.Errorf("Expected startupDelay=%v, got %v", startupDelay, workerWithDelay.startupDelay) + } +} diff --git a/pkg/appview/holdhealth/worker.go b/pkg/appview/holdhealth/worker.go index 03f2315..e61f53f 100644 --- a/pkg/appview/holdhealth/worker.go +++ b/pkg/appview/holdhealth/worker.go @@ -22,6 +22,7 @@ type Worker struct { cleanupTicker *time.Ticker stopChan chan struct{} wg sync.WaitGroup + startupDelay time.Duration } // NewWorker creates a new background worker @@ -32,6 +33,19 @@ func NewWorker(checker *Checker, db DBQuerier, refreshInterval time.Duration) *W refreshTicker: time.NewTicker(refreshInterval), cleanupTicker: time.NewTicker(30 * time.Minute), // Cleanup every 30 minutes stopChan: make(chan struct{}), + startupDelay: 0, // No delay by default for backward compatibility + } +} + +// NewWorkerWithStartupDelay creates a new background worker with a startup delay +func NewWorkerWithStartupDelay(checker *Checker, db DBQuerier, refreshInterval, startupDelay time.Duration) *Worker { + return &Worker{ + checker: checker, + db: db, + refreshTicker: time.NewTicker(refreshInterval), + cleanupTicker: time.NewTicker(30 * time.Minute), // Cleanup every 30 minutes + stopChan: make(chan struct{}), + startupDelay: startupDelay, } } @@ -43,7 +57,19 @@ func (w *Worker) Start(ctx context.Context) { log.Println("Hold health worker: Starting background health checks") - // Perform initial check immediately + // Wait for services to be ready (Docker startup race condition) + if w.startupDelay > 0 { + log.Printf("Hold health worker: Waiting %s for services to be ready...", w.startupDelay) + select { + case <-time.After(w.startupDelay): + // Continue with initial check + case <-ctx.Done(): + log.Println("Hold health worker: Context cancelled during startup delay") + return + } + } + + // Perform initial check w.refreshAllHolds(ctx) for {