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

Configure Feed

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

use confidential oauth in production

+857 -622
+10
.env.appview.example
··· 49 49 # JWT token expiration in seconds (default: 300 = 5 minutes) 50 50 # ATCR_TOKEN_EXPIRATION=300 51 51 52 + # Path to OAuth client P-256 signing key (auto-generated on first run) 53 + # Used for confidential OAuth client authentication (production only) 54 + # Localhost deployments always use public OAuth clients (no key needed) 55 + # Default: /var/lib/atcr/oauth/client.key 56 + # ATCR_OAUTH_KEY_PATH=/var/lib/atcr/oauth/client.key 57 + 58 + # OAuth client display name (shown in authorization screens) 59 + # Default: AT Container Registry 60 + # ATCR_CLIENT_NAME=AT Container Registry 61 + 52 62 # ============================================================================== 53 63 # UI Configuration 54 64 # ==============================================================================
+30 -8
CLAUDE.md
··· 221 221 **Key Components** (`pkg/auth/oauth/`): 222 222 223 223 1. **Client** (`client.go`) - Core OAuth client with encapsulated configuration 224 - - Constructor: `NewClient(baseURL)` - accepts base URL, derives client ID/redirect URI 225 - - `NewClientWithKey(baseURL, dpopKey)` - for token refresh with stored DPoP key 226 - - `ClientID()` - computes localhost vs production client ID dynamically 224 + - Uses indigo's `NewLocalhostConfig()` for localhost (public client) 225 + - Uses `NewPublicConfig()` for production base (upgraded to confidential if key provided) 227 226 - `RedirectURI()` - returns `baseURL + "/auth/oauth/callback"` 228 227 - `GetDefaultScopes()` - returns ATCR registry scopes 228 + - `GetConfigRef()` - returns mutable config for `SetClientSecret()` calls 229 229 - All OAuth flows (authorization, token exchange, refresh) in one place 230 230 231 - 2. **Token Storage** (`store.go`) - Persists OAuth sessions for AppView 232 - - File-based storage in `/var/lib/atcr/refresh-tokens.json` (AppView) 231 + 2. **Keys** (`keys.go`) - P-256 key management for confidential clients 232 + - `GenerateOrLoadClientKey()` - generates or loads P-256 key from disk 233 + - Follows hold service pattern: auto-generation, 0600 permissions, /var/lib/atcr/oauth/ 234 + - `GenerateKeyID()` - derives key ID from public key hash 235 + - `PrivateKeyToMultibase()` - converts key for `SetClientSecret()` API 236 + - **Key type:** P-256 (ES256) for OAuth standard compatibility (not K-256 like PDS keys) 237 + 238 + 3. **Token Storage** (`store.go`) - Persists OAuth sessions for AppView 239 + - SQLite-backed storage in UI database (not file-based) 233 240 - Client uses `~/.atcr/oauth-token.json` (credential helper) 234 241 235 - 3. **Refresher** (`refresher.go`) - Token refresh manager for AppView 242 + 4. **Refresher** (`refresher.go`) - Token refresh manager for AppView 236 243 - Caches OAuth sessions with automatic token refresh (handled by indigo library) 237 244 - Per-DID locking prevents concurrent refresh races 238 245 - Uses Client methods for consistency 239 246 240 - 4. **Server** (`server.go`) - OAuth authorization endpoints for AppView 247 + 5. **Server** (`server.go`) - OAuth authorization endpoints for AppView 241 248 - `GET /auth/oauth/authorize` - starts OAuth flow 242 249 - `GET /auth/oauth/callback` - handles OAuth callback 243 250 - Uses Client methods for authorization and token exchange 244 251 245 - 5. **Interactive Flow** (`interactive.go`) - Reusable OAuth flow for CLI tools 252 + 6. **Interactive Flow** (`interactive.go`) - Reusable OAuth flow for CLI tools 246 253 - Used by credential helper and hold service registration 247 254 - Two-phase callback setup ensures PAR metadata availability 255 + 256 + **Client Configuration:** 257 + - **Localhost:** Always public client (no client authentication) 258 + - Client ID: `http://localhost?redirect_uri=...&scope=...` (query-based) 259 + - No P-256 key generation 260 + - **Production:** Confidential client with P-256 private key (if key exists) 261 + - Client ID: `{baseURL}/client-metadata.json` (metadata endpoint) 262 + - Key path: `/var/lib/atcr/oauth/client.key` (auto-generated on first run) 263 + - Key algorithm: ES256 (P-256, not K-256) 264 + - Upgraded via `config.SetClientSecret(key, keyID)` 248 265 249 266 **Authentication Flow:** 250 267 ``` ··· 280 297 - No trust in client-provided identity information 281 298 - DPoP binds tokens to specific client key 282 299 - 15-minute token expiry for registry JWTs 300 + - **Confidential clients** (production): Client authentication via P-256 private key JWT assertion 301 + - Prevents client impersonation attacks 302 + - Key stored in `/var/lib/atcr/oauth/client.key` with 0600 permissions 303 + - Automatically generated on first run 304 + - **Public clients** (localhost): No client authentication (development only) 283 305 284 306 ### Key Components 285 307
+22 -4
cmd/appview/serve.go
··· 119 119 slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution and transition:generic scope") 120 120 } 121 121 122 - // Create OAuth app (indigo client) 123 - oauthApp, err := oauth.NewApp(baseURL, oauthStore, defaultHoldDID, testMode) 122 + // Create OAuth app (automatically configures confidential client for production) 123 + oauthApp, err := oauth.NewApp(baseURL, oauthStore, defaultHoldDID, cfg.Server.OAuthKeyPath, cfg.Server.ClientName) 124 124 if err != nil { 125 125 return fmt.Errorf("failed to create OAuth app: %w", err) 126 126 } ··· 132 132 133 133 // Invalidate sessions with mismatched scopes on startup 134 134 // This ensures all users have the latest required scopes after deployment 135 - desiredScopes := oauth.GetDefaultScopes(defaultHoldDID, testMode) 135 + desiredScopes := oauth.GetDefaultScopes(defaultHoldDID) 136 136 invalidatedCount, err := oauthStore.InvalidateSessionsWithMismatchedScopes(context.Background(), desiredScopes) 137 137 if err != nil { 138 138 slog.Warn("Failed to invalidate sessions with mismatched scopes", "error", err) ··· 385 385 config := oauthApp.GetConfig() 386 386 metadata := config.ClientMetadata() 387 387 388 + // Convert indigo's metadata to map so we can add custom fields 389 + metadataBytes, err := json.Marshal(metadata) 390 + if err != nil { 391 + http.Error(w, "Failed to marshal metadata", http.StatusInternalServerError) 392 + return 393 + } 394 + 395 + var metadataMap map[string]interface{} 396 + if err := json.Unmarshal(metadataBytes, &metadataMap); err != nil { 397 + http.Error(w, "Failed to unmarshal metadata", http.StatusInternalServerError) 398 + return 399 + } 400 + 401 + // Add custom fields 402 + metadataMap["client_name"] = cfg.Server.ClientName 403 + metadataMap["client_uri"] = cfg.Server.BaseURL 404 + metadataMap["logo_uri"] = cfg.Server.BaseURL + "/web-app-manifest-192x192.png" 405 + 388 406 w.Header().Set("Content-Type", "application/json") 389 407 w.Header().Set("Access-Control-Allow-Origin", "*") 390 - if err := json.NewEncoder(w).Encode(metadata); err != nil { 408 + if err := json.NewEncoder(w).Encode(metadataMap); err != nil { 391 409 http.Error(w, "Failed to encode metadata", http.StatusInternalServerError) 392 410 } 393 411 })
+4
deploy/.env.prod.template
··· 151 151 # Default: 300 (5 minutes) 152 152 ATCR_TOKEN_EXPIRATION=300 153 153 154 + # OAuth client display name (shown in authorization screens) 155 + # Default: AT Container Registry 156 + # ATCR_CLIENT_NAME=AT Container Registry 157 + 154 158 # Enable web UI 155 159 # Default: true 156 160 ATCR_UI_ENABLED=true
+99 -35
deploy/init-upcloud.sh
··· 130 130 log_warn "IMPORTANT: Edit $ATCR_DIR/.env with your configuration!" 131 131 fi 132 132 133 - # Create systemd service 134 - log_info "Creating systemd service..." 135 - cat > /etc/systemd/system/atcr.service <<'EOF' 133 + # Create systemd services (caddy, appview, hold) 134 + log_info "Creating systemd services..." 135 + 136 + # Caddy service (reverse proxy for both appview and hold) 137 + cat > /etc/systemd/system/atcr-caddy.service <<'EOF' 136 138 [Unit] 137 - Description=ATCR Container Registry 139 + Description=ATCR Caddy Reverse Proxy 138 140 Requires=docker.service 139 141 After=docker.service network-online.target 140 142 Wants=network-online.target ··· 145 147 WorkingDirectory=/opt/atcr 146 148 EnvironmentFile=/opt/atcr/.env 147 149 148 - # Start containers 149 - ExecStart=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml up -d 150 + # Start caddy container 151 + ExecStart=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml up -d caddy 152 + 153 + # Stop caddy container 154 + ExecStop=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml stop caddy 155 + 156 + # Restart caddy container 157 + ExecReload=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml restart caddy 158 + 159 + # Always restart on failure 160 + Restart=on-failure 161 + RestartSec=10 162 + 163 + [Install] 164 + WantedBy=multi-user.target 165 + EOF 166 + 167 + # AppView service (registry + web UI) 168 + cat > /etc/systemd/system/atcr-appview.service <<'EOF' 169 + [Unit] 170 + Description=ATCR AppView (Registry + Web UI) 171 + Requires=docker.service atcr-caddy.service 172 + After=docker.service network-online.target atcr-caddy.service 173 + Wants=network-online.target 174 + 175 + [Service] 176 + Type=oneshot 177 + RemainAfterExit=yes 178 + WorkingDirectory=/opt/atcr 179 + EnvironmentFile=/opt/atcr/.env 180 + 181 + # Start appview container 182 + ExecStart=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml up -d atcr-appview 150 183 151 - # Stop containers 152 - ExecStop=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml down 184 + # Stop appview container 185 + ExecStop=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml stop atcr-appview 153 186 154 - # Restart containers 155 - ExecReload=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml restart 187 + # Restart appview container 188 + ExecReload=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml restart atcr-appview 189 + 190 + # Always restart on failure 191 + Restart=on-failure 192 + RestartSec=10 193 + 194 + [Install] 195 + WantedBy=multi-user.target 196 + EOF 197 + 198 + # Hold service (storage backend) 199 + cat > /etc/systemd/system/atcr-hold.service <<'EOF' 200 + [Unit] 201 + Description=ATCR Hold (Storage Service) 202 + Requires=docker.service atcr-caddy.service 203 + After=docker.service network-online.target atcr-caddy.service 204 + Wants=network-online.target 205 + 206 + [Service] 207 + Type=oneshot 208 + RemainAfterExit=yes 209 + WorkingDirectory=/opt/atcr 210 + EnvironmentFile=/opt/atcr/.env 211 + 212 + # Start hold container 213 + ExecStart=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml up -d atcr-hold 214 + 215 + # Stop hold container 216 + ExecStop=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml stop atcr-hold 217 + 218 + # Restart hold container 219 + ExecReload=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml restart atcr-hold 156 220 157 221 # Always restart on failure 158 222 Restart=on-failure ··· 166 230 log_info "Reloading systemd daemon..." 167 231 systemctl daemon-reload 168 232 169 - # Enable service (but don't start yet - user needs to configure .env) 170 - systemctl enable atcr.service 233 + # Enable all services (but don't start yet - user needs to configure .env) 234 + systemctl enable atcr-caddy.service 235 + systemctl enable atcr-appview.service 236 + systemctl enable atcr-hold.service 171 237 172 - log_info "Systemd service created and enabled" 238 + log_info "Systemd services created and enabled" 173 239 174 240 # Create helper scripts 175 241 log_info "Creating helper scripts..." ··· 192 258 docker compose -f deploy/docker-compose.prod.yml logs -f "$@" 193 259 EOF 194 260 chmod +x "$ATCR_DIR/logs.sh" 195 - 196 - # Script to get hold OAuth URL 197 - cat > "$ATCR_DIR/get-hold-oauth.sh" <<'EOF' 198 - #!/bin/bash 199 - echo "Checking atcr-hold logs for OAuth registration URL..." 200 - docker logs atcr-hold 2>&1 | grep -i "oauth\|authorization\|visit\|http" | tail -20 201 - EOF 202 - chmod +x "$ATCR_DIR/get-hold-oauth.sh" 203 261 204 262 log_info "Helper scripts created in $ATCR_DIR" 205 263 ··· 241 299 242 300 cat <<'EOF' 243 301 244 - 4. Start ATCR: 245 - systemctl start atcr 302 + 4. Start ATCR services: 303 + systemctl start atcr-caddy atcr-appview atcr-hold 246 304 247 - 5. Complete Hold OAuth registration: 248 - /opt/atcr/get-hold-oauth.sh 249 - 250 - Visit the OAuth URL in your browser to authorize the hold service. 251 - 252 - 6. Check status: 253 - systemctl status atcr 305 + 5. Check status: 306 + systemctl status atcr-caddy 307 + systemctl status atcr-appview 308 + systemctl status atcr-hold 254 309 docker ps 255 310 /opt/atcr/logs.sh 256 311 257 312 Helper Scripts: 258 313 /opt/atcr/rebuild.sh - Rebuild and restart containers 259 314 /opt/atcr/logs.sh [service] - View logs (e.g., logs.sh atcr-hold) 260 - /opt/atcr/get-hold-oauth.sh - Get hold OAuth URL 261 315 262 316 Service Management: 263 - systemctl start atcr - Start ATCR 264 - systemctl stop atcr - Stop ATCR 265 - systemctl restart atcr - Restart ATCR 266 - systemctl status atcr - Check status 317 + systemctl start atcr-caddy - Start Caddy reverse proxy 318 + systemctl start atcr-appview - Start AppView (registry + UI) 319 + systemctl start atcr-hold - Start Hold (storage service) 320 + 321 + systemctl stop atcr-appview - Stop AppView only 322 + systemctl stop atcr-hold - Stop Hold only 323 + systemctl stop atcr-caddy - Stop all (stops reverse proxy) 324 + 325 + systemctl restart atcr-appview - Restart AppView 326 + systemctl restart atcr-hold - Restart Hold 327 + 328 + systemctl status atcr-caddy - Check Caddy status 329 + systemctl status atcr-appview - Check AppView status 330 + systemctl status atcr-hold - Check Hold status 267 331 268 332 Documentation: 269 333 https://tangled.org/@evan.jarrett.net/at-container-registry
+399
docs/OAUTH.md
··· 1 + # OAuth Implementation in ATCR 2 + 3 + This document describes ATCR's OAuth implementation, which uses the ATProto OAuth specification with DPoP (Demonstrating Proof of Possession) for secure authentication. 4 + 5 + ## Overview 6 + 7 + ATCR implements a full OAuth 2.0 + DPoP flow following the ATProto specification. The implementation uses the [indigo OAuth library](https://github.com/bluesky-social/indigo) and extends it with ATCR-specific configuration for registry operations. 8 + 9 + ### Key Features 10 + 11 + - **DPoP (RFC 9449)**: Cryptographic proof-of-possession binds tokens to specific client keys 12 + - **PAR (RFC 9126)**: Pushed Authorization Requests for secure server-to-server parameter exchange 13 + - **PKCE (RFC 7636)**: Proof Key for Code Exchange prevents authorization code interception 14 + - **Confidential Clients**: Production deployments use P-256 private keys for client authentication 15 + - **Public Clients**: Development (localhost) uses simpler public client configuration 16 + 17 + ## Client Types 18 + 19 + ATCR supports two OAuth client types depending on the deployment environment: 20 + 21 + ### Public Clients (Development) 22 + 23 + **When:** `baseURL` contains `localhost` or `127.0.0.1` 24 + 25 + **Configuration:** 26 + - Client ID: `http://localhost?redirect_uri=...&scope=...` (query-based) 27 + - No client authentication 28 + - Uses indigo's `NewLocalhostConfig()` helper 29 + - DPoP still required for token requests 30 + 31 + **Example:** 32 + ```go 33 + // Automatically uses public client for localhost 34 + config := oauth.NewClientConfigWithScopes("http://127.0.0.1:5000", scopes) 35 + ``` 36 + 37 + ### Confidential Clients (Production) 38 + 39 + **When:** `baseURL` is a public domain (not localhost) 40 + 41 + **Configuration:** 42 + - Client ID: `{baseURL}/client-metadata.json` (metadata endpoint) 43 + - Client authentication: P-256 (ES256) private key JWT assertion 44 + - Private key stored at `/var/lib/atcr/oauth/client.key` 45 + - Auto-generated on first run with 0600 permissions 46 + - Upgraded via `config.SetClientSecret(privateKey, keyID)` 47 + 48 + **Example:** 49 + ```go 50 + // 1. Create base config (public) 51 + config := oauth.NewClientConfigWithScopes("https://atcr.io", scopes) 52 + 53 + // 2. Load or generate P-256 key 54 + privateKey, err := oauth.GenerateOrLoadClientKey("/var/lib/atcr/oauth/client.key") 55 + 56 + // 3. Generate key ID 57 + keyID, err := oauth.GenerateKeyID(privateKey) 58 + 59 + // 4. Upgrade to confidential 60 + err = config.SetClientSecret(privateKey, keyID) 61 + ``` 62 + 63 + ## Key Management 64 + 65 + ### P-256 Key Generation 66 + 67 + ATCR uses **P-256 (NIST P-256, ES256)** keys for OAuth client authentication. This differs from the K-256 keys used for ATProto PDS signing. 68 + 69 + **Why P-256?** 70 + - Standard OAuth/OIDC key algorithm 71 + - Widely supported by authorization servers 72 + - Compatible with indigo's `SetClientSecret()` API 73 + 74 + **Key Storage:** 75 + - Default path: `/var/lib/atcr/oauth/client.key` 76 + - Configurable via: `ATCR_OAUTH_KEY_PATH` environment variable 77 + - File permissions: `0600` (owner read/write only) 78 + - Directory permissions: `0700` (owner access only) 79 + - Format: Raw binary bytes (not PEM) 80 + 81 + **Key Lifecycle:** 82 + 1. On first production startup, AppView checks for key at configured path 83 + 2. If missing, generates new P-256 key using `atcrypto.GeneratePrivateKeyP256()` 84 + 3. Saves raw key bytes to disk with restrictive permissions 85 + 4. Logs generation event: `"Generated new P-256 OAuth client key"` 86 + 5. On subsequent startups, loads existing key 87 + 6. Logs load event: `"Loaded existing P-256 OAuth client key"` 88 + 89 + **Key Rotation:** 90 + To rotate the OAuth client key: 91 + 1. Stop the AppView service 92 + 2. Delete or rename the existing key file 93 + 3. Restart AppView (new key will be generated automatically) 94 + 4. Note: Active OAuth sessions may need re-authentication 95 + 96 + ### Key ID Generation 97 + 98 + The key ID is derived from the public key for stable identification: 99 + 100 + ```go 101 + func GenerateKeyID(privateKey *atcrypto.PrivateKeyP256) (string, error) { 102 + pubKey, _ := privateKey.PublicKey() 103 + pubKeyBytes := pubKey.Bytes() 104 + hash := sha256.Sum256(pubKeyBytes) 105 + return hex.EncodeToString(hash[:])[:8], nil 106 + } 107 + ``` 108 + 109 + This generates an 8-character hex ID from the SHA-256 hash of the public key. 110 + 111 + ## Authentication Flow 112 + 113 + ### AppView OAuth Flow 114 + 115 + ```mermaid 116 + sequenceDiagram 117 + participant User 118 + participant Browser 119 + participant AppView 120 + participant PDS 121 + 122 + User->>Browser: docker push atcr.io/alice/myapp 123 + Browser->>AppView: Credential helper redirects 124 + AppView->>PDS: Resolve handle → DID 125 + AppView->>PDS: Discover OAuth metadata 126 + AppView->>PDS: PAR request (with DPoP) 127 + PDS-->>AppView: request_uri 128 + AppView->>Browser: Redirect to authorization page 129 + Browser->>PDS: User authorizes 130 + PDS->>AppView: Authorization code 131 + AppView->>PDS: Token exchange (with DPoP) 132 + PDS-->>AppView: OAuth tokens + DPoP binding 133 + AppView->>User: Issue registry JWT 134 + ``` 135 + 136 + ### Key Steps 137 + 138 + 1. **Identity Resolution** 139 + - AppView resolves handle to DID via `.well-known/atproto-did` 140 + - Resolves DID to PDS endpoint via DID document 141 + 142 + 2. **OAuth Discovery** 143 + - Fetches `/.well-known/oauth-authorization-server` from PDS 144 + - Extracts `authorization_endpoint`, `token_endpoint`, etc. 145 + 146 + 3. **Pushed Authorization Request (PAR)** 147 + - AppView sends authorization parameters to PDS token endpoint 148 + - Includes DPoP header with proof JWT 149 + - Receives `request_uri` for authorization 150 + 151 + 4. **User Authorization** 152 + - User is redirected to PDS authorization page 153 + - User approves application access 154 + - PDS redirects back with authorization code 155 + 156 + 5. **Token Exchange** 157 + - AppView exchanges code for tokens at PDS token endpoint 158 + - Includes DPoP header with proof JWT 159 + - Receives access token, refresh token (both DPoP-bound) 160 + 161 + 6. **Token Storage** 162 + - AppView stores OAuth session in SQLite database 163 + - Indigo library manages token refresh automatically 164 + - DPoP key stored with session for future requests 165 + 166 + 7. **Registry JWT Issuance** 167 + - AppView validates OAuth session 168 + - Issues short-lived registry JWT (15 minutes) 169 + - JWT contains validated DID from PDS session 170 + 171 + ## DPoP Implementation 172 + 173 + ### What is DPoP? 174 + 175 + DPoP (Demonstrating Proof of Possession) binds OAuth tokens to a specific client key, preventing token theft and replay attacks. 176 + 177 + **How it works:** 178 + 1. Client generates ephemeral key pair (or uses persistent key) 179 + 2. Client includes DPoP proof JWT in Authorization header 180 + 3. Proof JWT contains hash of HTTP request details 181 + 4. Authorization server validates proof and issues DPoP-bound token 182 + 5. Token can only be used with the same client key 183 + 184 + ### DPoP Headers 185 + 186 + Every request to the PDS token endpoint includes a DPoP header: 187 + 188 + ```http 189 + POST /oauth/token HTTP/1.1 190 + Host: pds.example.com 191 + Content-Type: application/x-www-form-urlencoded 192 + DPoP: eyJhbGciOiJFUzI1NiIsInR5cCI6ImRwb3Arand0IiwiandrIjp7Imt0eSI6Ik... 193 + 194 + grant_type=authorization_code&code=...&redirect_uri=... 195 + ``` 196 + 197 + The DPoP header is a signed JWT containing: 198 + - `htm`: HTTP method (e.g., "POST") 199 + - `htu`: HTTP URI (e.g., "https://pds.example.com/oauth/token") 200 + - `jti`: Unique request identifier 201 + - `iat`: Timestamp 202 + - `jwk`: Public key (JWK format) 203 + 204 + ### Indigo DPoP Management 205 + 206 + ATCR uses indigo's built-in DPoP management: 207 + 208 + ```go 209 + // Indigo automatically handles DPoP 210 + clientApp := oauth.NewClientApp(&config, store) 211 + 212 + // All token requests include DPoP automatically 213 + tokens, err := clientApp.ProcessCallback(ctx, params) 214 + 215 + // Refresh automatically includes DPoP 216 + session, err := clientApp.ResumeSession(ctx, did, sessionID) 217 + ``` 218 + 219 + Indigo manages: 220 + - DPoP key generation and storage 221 + - DPoP proof JWT creation 222 + - DPoP header inclusion in token requests 223 + - Token binding to DPoP keys 224 + 225 + ## Client Configuration 226 + 227 + ### Environment Variables 228 + 229 + **ATCR_OAUTH_KEY_PATH** 230 + - Path to OAuth client P-256 signing key 231 + - Default: `/var/lib/atcr/oauth/client.key` 232 + - Auto-generated on first run (production only) 233 + - Format: Raw binary P-256 private key 234 + 235 + **ATCR_BASE_URL** 236 + - Public URL of AppView service 237 + - Required for OAuth redirect URIs 238 + - Example: `https://atcr.io` 239 + - Determines client type (public vs confidential) 240 + 241 + **ATCR_UI_DATABASE_PATH** 242 + - Path to SQLite database (includes OAuth session storage) 243 + - Default: `/var/lib/atcr/ui.db` 244 + 245 + ### Client Metadata Endpoint 246 + 247 + Production deployments serve OAuth client metadata at `{baseURL}/client-metadata.json`: 248 + 249 + ```json 250 + { 251 + "client_id": "https://atcr.io/client-metadata.json", 252 + "client_name": "ATCR Registry", 253 + "client_uri": "https://atcr.io", 254 + "redirect_uris": ["https://atcr.io/auth/oauth/callback"], 255 + "scope": "atproto blob:... repo:...", 256 + "grant_types": ["authorization_code", "refresh_token"], 257 + "response_types": ["code"], 258 + "token_endpoint_auth_method": "private_key_jwt", 259 + "token_endpoint_auth_signing_alg": "ES256", 260 + "jwks": { 261 + "keys": [ 262 + { 263 + "kty": "EC", 264 + "crv": "P-256", 265 + "x": "...", 266 + "y": "...", 267 + "kid": "abc12345" 268 + } 269 + ] 270 + } 271 + } 272 + ``` 273 + 274 + For localhost, the client ID is query-based and no metadata endpoint is used. 275 + 276 + ## Scope Management 277 + 278 + ATCR requests the following OAuth scopes: 279 + 280 + **Base scopes:** 281 + - `atproto`: Basic ATProto access 282 + 283 + **Blob scopes (for layer/manifest media types):** 284 + - `blob:application/vnd.oci.image.manifest.v1+json` 285 + - `blob:application/vnd.docker.distribution.manifest.v2+json` 286 + - `blob:application/vnd.oci.image.index.v1+json` 287 + - `blob:application/vnd.docker.distribution.manifest.list.v2+json` 288 + - `blob:application/vnd.cncf.oras.artifact.manifest.v1+json` 289 + 290 + **Repo scopes (for ATProto collections):** 291 + - `repo:io.atcr.manifest`: Manifest records 292 + - `repo:io.atcr.tag`: Tag records 293 + - `repo:io.atcr.star`: Star records 294 + - `repo:io.atcr.sailor.profile`: User profile records 295 + 296 + **RPC scope:** 297 + - `rpc:com.atproto.repo.getRecord?aud=*`: Read access to any user's records 298 + 299 + Scopes are automatically invalidated on startup if they change, forcing users to re-authenticate. 300 + 301 + ## Security Considerations 302 + 303 + ### Token Security 304 + 305 + **OAuth Tokens (managed by AppView):** 306 + - Stored in SQLite database 307 + - DPoP-bound (cannot be used without client key) 308 + - Automatically refreshed by indigo library 309 + - Used for PDS API requests (manifests, service tokens) 310 + 311 + **Registry JWTs (issued to Docker clients):** 312 + - Short-lived (15 minutes) 313 + - Signed by AppView's JWT signing key 314 + - Contain validated DID from OAuth session 315 + - Used for OCI Distribution API requests 316 + 317 + ### Attack Prevention 318 + 319 + **Token Theft:** 320 + - DPoP prevents stolen tokens from being used 321 + - Tokens are bound to specific client key 322 + - Attacker would need both token AND private key 323 + 324 + **Client Impersonation:** 325 + - Confidential clients use private key JWT assertion 326 + - Prevents attackers from impersonating AppView 327 + - Public keys published in client metadata JWKS 328 + 329 + **Man-in-the-Middle:** 330 + - All OAuth flows use HTTPS in production 331 + - DPoP includes HTTP method and URI in proof 332 + - Prevents replay attacks on different endpoints 333 + 334 + **Authorization Code Interception:** 335 + - PKCE prevents code interception attacks 336 + - Code verifier required to exchange code for token 337 + - Protects against malicious redirect URI attacks 338 + 339 + ## Troubleshooting 340 + 341 + ### Common Issues 342 + 343 + **"Failed to initialize OAuth client key"** 344 + - Check that `/var/lib/atcr/oauth/` directory exists and is writable 345 + - Verify directory permissions are 0700 346 + - Check disk space 347 + 348 + **"OAuth session not found"** 349 + - User needs to re-authenticate (session expired or invalidated) 350 + - Check that UI database is accessible 351 + - Verify OAuth session storage is working 352 + 353 + **"Invalid DPoP proof"** 354 + - Clock skew between AppView and PDS 355 + - DPoP key mismatch (token was issued with different key) 356 + - Check that indigo library is managing DPoP correctly 357 + 358 + **"Client authentication failed"** 359 + - Confidential client key may be corrupted 360 + - Key ID may not match public key 361 + - Try rotating the client key (delete and regenerate) 362 + 363 + ### Debugging 364 + 365 + Enable debug logging to see OAuth flow details: 366 + 367 + ```bash 368 + export ATCR_LOG_LEVEL=debug 369 + ./bin/atcr-appview serve 370 + ``` 371 + 372 + Look for log messages: 373 + - `"Generated new P-256 OAuth client key"` - Key was auto-generated 374 + - `"Loaded existing P-256 OAuth client key"` - Key was loaded from disk 375 + - `"Configured confidential OAuth client"` - Production confidential client active 376 + - `"Localhost detected - using public OAuth client"` - Development public client active 377 + 378 + ### Testing OAuth Flow 379 + 380 + Test OAuth flow manually: 381 + 382 + ```bash 383 + # 1. Start AppView in debug mode 384 + ATCR_LOG_LEVEL=debug ./bin/atcr-appview serve 385 + 386 + # 2. Try docker login 387 + docker login atcr.io 388 + 389 + # 3. Check logs for OAuth flow details 390 + # Look for: PAR request, token exchange, DPoP headers, etc. 391 + ``` 392 + 393 + ## References 394 + 395 + - [ATProto OAuth Specification](https://atproto.com/specs/oauth) 396 + - [RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP)](https://datatracker.ietf.org/doc/html/rfc9449) 397 + - [RFC 9126: OAuth 2.0 Pushed Authorization Requests (PAR)](https://datatracker.ietf.org/doc/html/rfc9126) 398 + - [RFC 7636: Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636) 399 + - [Indigo OAuth Library](https://github.com/bluesky-social/indigo/tree/main/atproto/auth/oauth)
+10
pkg/appview/config.go
··· 48 48 49 49 // DebugAddr is the debug/pprof HTTP listen address (from env: ATCR_DEBUG_ADDR, default: ":5001") 50 50 DebugAddr string `yaml:"debug_addr"` 51 + 52 + // OAuthKeyPath is the path to the OAuth client P-256 signing key (from env: ATCR_OAUTH_KEY_PATH, default: "/var/lib/atcr/oauth/client.key") 53 + // Auto-generated on first run for production (non-localhost) deployments 54 + OAuthKeyPath string `yaml:"oauth_key_path"` 55 + 56 + // ClientName is the OAuth client display name (from env: ATCR_CLIENT_NAME, default: "AT Container Registry") 57 + // Shown in OAuth authorization screens 58 + ClientName string `yaml:"client_name"` 51 59 } 52 60 53 61 // UIConfig defines web UI settings ··· 123 131 return nil, fmt.Errorf("ATCR_DEFAULT_HOLD_DID is required") 124 132 } 125 133 cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true" 134 + cfg.Server.OAuthKeyPath = getEnvOrDefault("ATCR_OAUTH_KEY_PATH", "/var/lib/atcr/oauth/client.key") 135 + cfg.Server.ClientName = getEnvOrDefault("ATCR_CLIENT_NAME", "AT Container Registry") 126 136 127 137 // Auto-detect base URL if not explicitly set 128 138 cfg.Server.BaseURL = os.Getenv("ATCR_BASE_URL")
+2 -2
pkg/appview/static/site.webmanifest
··· 1 1 { 2 - "name": "MyWebSite", 3 - "short_name": "MySite", 2 + "name": "At Container Registry", 3 + "short_name": "ATCR.io", 4 4 "icons": [ 5 5 { 6 6 "src": "/web-app-manifest-192x192.png",
+60 -37
pkg/auth/oauth/client.go
··· 7 7 import ( 8 8 "context" 9 9 "fmt" 10 + "log/slog" 10 11 "net/url" 11 12 "strings" 12 13 ··· 23 24 } 24 25 25 26 // NewApp creates a new OAuth app for ATCR with default scopes 26 - func NewApp(baseURL string, store oauth.ClientAuthStore, holdDid string, testMode bool) (*App, error) { 27 - return NewAppWithScopes(baseURL, store, GetDefaultScopes(holdDid, testMode)) 27 + func NewApp(baseURL string, store oauth.ClientAuthStore, holdDid string, keyPath string, clientName string) (*App, error) { 28 + return NewAppWithScopes(baseURL, store, GetDefaultScopes(holdDid), keyPath, clientName) 28 29 } 29 30 30 31 // NewAppWithScopes creates a new OAuth app for ATCR with custom scopes 31 - func NewAppWithScopes(baseURL string, store oauth.ClientAuthStore, scopes []string) (*App, error) { 32 - config := NewClientConfigWithScopes(baseURL, scopes) 32 + // Automatically configures confidential client for production deployments 33 + // keyPath specifies where to store/load the OAuth client P-256 key (ignored for localhost) 34 + // clientName is added to OAuth client metadata 35 + func NewAppWithScopes(baseURL string, store oauth.ClientAuthStore, scopes []string, keyPath string, clientName string) (*App, error) { 36 + var config oauth.ClientConfig 37 + redirectURI := RedirectURI(baseURL) 38 + 39 + // If production (not localhost), automatically set up confidential client 40 + if !isLocalhost(baseURL) { 41 + clientID := baseURL + "/client-metadata.json" 42 + config = oauth.NewPublicConfig(clientID, redirectURI, scopes) 43 + 44 + // Generate or load P-256 key 45 + privateKey, err := GenerateOrLoadClientKey(keyPath) 46 + if err != nil { 47 + return nil, fmt.Errorf("failed to load OAuth client key: %w", err) 48 + } 49 + 50 + // Generate key ID from public key 51 + keyID, err := GenerateKeyID(privateKey) 52 + if err != nil { 53 + return nil, fmt.Errorf("failed to generate key ID: %w", err) 54 + } 55 + 56 + // Upgrade to confidential client 57 + if err := config.SetClientSecret(privateKey, keyID); err != nil { 58 + return nil, fmt.Errorf("failed to configure confidential client: %w", err) 59 + } 60 + 61 + slog.Info("Configured confidential OAuth client", "key_id", keyID, "key_path", keyPath) 62 + } else { 63 + config = oauth.NewLocalhostConfig(redirectURI, scopes) 64 + 65 + // Append client_name to localhost client ID query string 66 + if clientName != "" { 67 + u, err := url.Parse(config.ClientID) 68 + if err == nil { 69 + q := u.Query() 70 + q.Set("client_name", clientName) 71 + u.RawQuery = q.Encode() 72 + config.ClientID = u.String() 73 + } 74 + } 75 + 76 + slog.Info("Using public OAuth client (localhost development)") 77 + } 78 + 33 79 clientApp := oauth.NewClientApp(&config, store) 34 80 clientApp.Dir = atproto.GetDirectory() 35 81 ··· 39 85 }, nil 40 86 } 41 87 42 - // NewClientConfigWithScopes creates an OAuth client configuration with custom scopes 43 - func NewClientConfigWithScopes(baseURL string, scopes []string) oauth.ClientConfig { 44 - clientID := ClientIDWithScopes(baseURL, scopes) 45 - redirectURI := RedirectURI(baseURL) 46 - 47 - // Check if this is localhost (public client) or production (confidential client) 48 - if strings.Contains(baseURL, "127.0.0.1") || strings.Contains(baseURL, "localhost") { 49 - return oauth.NewPublicConfig(clientID, redirectURI, scopes) 50 - } 51 - 52 - // Production: confidential client 53 - // Note: Client secrets would be configured separately if needed 54 - return oauth.NewPublicConfig(clientID, redirectURI, scopes) 55 - } 56 - 57 - func (a *App) GetConfig() oauth.ClientConfig { 58 - return *a.clientApp.Config 88 + func (a *App) GetConfig() *oauth.ClientConfig { 89 + return a.clientApp.Config 59 90 } 60 91 61 92 // StartAuthFlow initiates an OAuth authorization flow for a given handle ··· 104 135 return a.clientApp.Dir 105 136 } 106 137 107 - // ClientIDWithScopes generates a client ID with custom scopes 108 - func ClientIDWithScopes(baseURL string, scopes []string) string { 109 - scopeStr := strings.Join(scopes, " ") 110 - if strings.Contains(baseURL, "127.0.0.1") || strings.Contains(baseURL, "localhost") { 111 - // Localhost: use query-based client ID 112 - return fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s", 113 - url.QueryEscape(RedirectURI(baseURL)), 114 - url.QueryEscape(scopeStr)) 115 - } 116 - // Production: use metadata URL 117 - return baseURL + "/client-metadata.json" 118 - } 119 - 120 138 // RedirectURI returns the OAuth redirect URI for ATCR 121 139 func RedirectURI(baseURL string) string { 122 140 return baseURL + "/auth/oauth/callback" ··· 124 142 125 143 // GetDefaultScopes returns the default OAuth scopes for ATCR registry operations 126 144 // testMode determines whether to use transition:generic (test) or rpc scopes (production) 127 - func GetDefaultScopes(did string, testMode bool) []string { 145 + func GetDefaultScopes(did string) []string { 128 146 scopes := []string{ 129 147 "atproto", 130 148 // Image manifest types (single-arch) ··· 135 153 "blob:application/vnd.docker.distribution.manifest.list.v2+json", 136 154 // OCI artifact manifests (for cosign signatures, SBOMs, attestations) 137 155 "blob:application/vnd.cncf.oras.artifact.manifest.v1+json", 156 + // Used for service token validation on holds 157 + "rpc:com.atproto.repo.getRecord?aud=*", 138 158 } 139 - 140 - scopes = append(scopes, fmt.Sprintf("rpc:com.atproto.repo.getRecord?aud=%s", "*")) 141 159 142 160 // Add repo scopes 143 161 scopes = append(scopes, ··· 176 194 177 195 return true 178 196 } 197 + 198 + // isLocalhost checks if a base URL is a localhost address 199 + func isLocalhost(baseURL string) bool { 200 + return strings.Contains(baseURL, "127.0.0.1") || strings.Contains(baseURL, "localhost") 201 + }
+4 -2
pkg/auth/oauth/client_test.go
··· 7 7 func TestNewApp(t *testing.T) { 8 8 tmpDir := t.TempDir() 9 9 storePath := tmpDir + "/oauth-test.json" 10 + keyPath := tmpDir + "/oauth-key.bin" 10 11 11 12 store, err := NewFileStore(storePath) 12 13 if err != nil { ··· 16 17 baseURL := "http://localhost:5000" 17 18 holdDID := "did:web:hold.example.com" 18 19 19 - app, err := NewApp(baseURL, store, holdDID, false) 20 + app, err := NewApp(baseURL, store, holdDID, keyPath, "AT Container Registry") 20 21 if err != nil { 21 22 t.Fatalf("NewApp() error = %v", err) 22 23 } ··· 33 34 func TestNewAppWithScopes(t *testing.T) { 34 35 tmpDir := t.TempDir() 35 36 storePath := tmpDir + "/oauth-test.json" 37 + keyPath := tmpDir + "/oauth-key.bin" 36 38 37 39 store, err := NewFileStore(storePath) 38 40 if err != nil { ··· 42 44 baseURL := "http://localhost:5000" 43 45 scopes := []string{"atproto", "custom:scope"} 44 46 45 - app, err := NewAppWithScopes(baseURL, store, scopes) 47 + app, err := NewAppWithScopes(baseURL, store, scopes, keyPath, "AT Container Registry") 46 48 if err != nil { 47 49 t.Fatalf("NewAppWithScopes() error = %v", err) 48 50 }
+4 -2
pkg/auth/oauth/interactive.go
··· 35 35 // Create OAuth app with custom scopes (or defaults if nil) 36 36 // Interactive flows are typically for production use (credential helper, etc.) 37 37 // so we default to testMode=false 38 + // For CLI tools, we use an empty keyPath since they're typically localhost (public client) 39 + // or ephemeral sessions 38 40 var app *App 39 41 if scopes != nil { 40 - app, err = NewAppWithScopes(baseURL, store, scopes) 42 + app, err = NewAppWithScopes(baseURL, store, scopes, "", "AT Container Registry") 41 43 } else { 42 - app, err = NewApp(baseURL, store, "*", false) 44 + app, err = NewApp(baseURL, store, "*", "", "AT Container Registry") 43 45 } 44 46 if err != nil { 45 47 return nil, fmt.Errorf("failed to create OAuth app: %w", err)
+193
pkg/auth/oauth/keys.go
··· 1 + package oauth 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/hex" 6 + "fmt" 7 + "log/slog" 8 + "os" 9 + "path/filepath" 10 + 11 + "github.com/bluesky-social/indigo/atproto/atcrypto" 12 + ) 13 + 14 + // KeyType represents the elliptic curve algorithm for key generation 15 + type KeyType int 16 + 17 + const ( 18 + // KeyTypeP256 uses NIST P-256 (ES256) - standard for OAuth/OIDC 19 + KeyTypeP256 KeyType = iota 20 + // KeyTypeK256 uses secp256k1 (ES256K) - used for ATProto PDS signing 21 + KeyTypeK256 22 + ) 23 + 24 + // GenerateOrLoadKey generates a new key pair or loads an existing one 25 + // Supports both P-256 (OAuth) and K-256 (ATProto PDS) key types 26 + func GenerateOrLoadKey(keyPath string, keyType KeyType) (atcrypto.PrivateKey, error) { 27 + // Ensure directory exists 28 + dir := filepath.Dir(keyPath) 29 + if err := os.MkdirAll(dir, 0700); err != nil { 30 + return nil, fmt.Errorf("failed to create key directory: %w", err) 31 + } 32 + 33 + // Check if key already exists 34 + if _, err := os.Stat(keyPath); err == nil { 35 + // Key exists, load it 36 + return loadKey(keyPath, keyType) 37 + } 38 + 39 + // Key doesn't exist, generate new one 40 + return generateKey(keyPath, keyType) 41 + } 42 + 43 + // generateKey creates a new key pair of the specified type 44 + func generateKey(keyPath string, keyType KeyType) (atcrypto.PrivateKey, error) { 45 + var privateKey atcrypto.PrivateKey 46 + var keyName string 47 + var err error 48 + 49 + switch keyType { 50 + case KeyTypeP256: 51 + privateKey, err = atcrypto.GeneratePrivateKeyP256() 52 + keyName = "P-256" 53 + case KeyTypeK256: 54 + privateKey, err = atcrypto.GeneratePrivateKeyK256() 55 + keyName = "K-256" 56 + default: 57 + return nil, fmt.Errorf("unsupported key type: %d", keyType) 58 + } 59 + 60 + if err != nil { 61 + return nil, fmt.Errorf("failed to generate %s key: %w", keyName, err) 62 + } 63 + 64 + // Serialize key to bytes 65 + exportableKey, ok := privateKey.(atcrypto.PrivateKeyExportable) 66 + if !ok { 67 + return nil, fmt.Errorf("key does not support export") 68 + } 69 + keyBytes := exportableKey.Bytes() 70 + 71 + // Write to file with restrictive permissions 72 + if err := os.WriteFile(keyPath, keyBytes, 0600); err != nil { 73 + return nil, fmt.Errorf("failed to write key file: %w", err) 74 + } 75 + 76 + slog.Info("Generated new signing key", "type", keyName, "path", keyPath) 77 + return privateKey, nil 78 + } 79 + 80 + // loadKey loads an existing private key from disk 81 + func loadKey(keyPath string, keyType KeyType) (atcrypto.PrivateKey, error) { 82 + // Read key bytes 83 + keyBytes, err := os.ReadFile(keyPath) 84 + if err != nil { 85 + return nil, fmt.Errorf("failed to read key file: %w", err) 86 + } 87 + 88 + var privateKey atcrypto.PrivateKey 89 + var keyName string 90 + 91 + switch keyType { 92 + case KeyTypeP256: 93 + privateKey, err = atcrypto.ParsePrivateBytesP256(keyBytes) 94 + keyName = "P-256" 95 + case KeyTypeK256: 96 + // Check for old PEM format (migration path) 97 + if IsPEMFormat(keyBytes) { 98 + slog.Warn("Detected old P-256 PEM key, replacing with K-256") 99 + return generateKey(keyPath, keyType) 100 + } 101 + privateKey, err = atcrypto.ParsePrivateBytesK256(keyBytes) 102 + keyName = "K-256" 103 + default: 104 + return nil, fmt.Errorf("unsupported key type: %d", keyType) 105 + } 106 + 107 + if err != nil { 108 + return nil, fmt.Errorf("failed to parse %s private key: %w", keyName, err) 109 + } 110 + 111 + slog.Info("Loaded existing signing key", "type", keyName, "path", keyPath) 112 + return privateKey, nil 113 + } 114 + 115 + // IsPEMFormat checks if bytes are in PEM format (for migration detection) 116 + // Exported for testing and migration utilities 117 + func IsPEMFormat(data []byte) bool { 118 + return len(data) > 10 && string(data[:5]) == "-----" 119 + } 120 + 121 + // GenerateOrLoadClientKey generates a new P256 key pair or loads an existing one 122 + // This is a convenience wrapper for OAuth client keys 123 + func GenerateOrLoadClientKey(keyPath string) (*atcrypto.PrivateKeyP256, error) { 124 + key, err := GenerateOrLoadKey(keyPath, KeyTypeP256) 125 + if err != nil { 126 + return nil, err 127 + } 128 + 129 + p256Key, ok := key.(*atcrypto.PrivateKeyP256) 130 + if !ok { 131 + return nil, fmt.Errorf("expected P-256 key, got different type") 132 + } 133 + 134 + return p256Key, nil 135 + } 136 + 137 + // GenerateOrLoadPDSKey generates a new K256 key pair or loads an existing one 138 + // This is a convenience wrapper for ATProto PDS signing keys 139 + func GenerateOrLoadPDSKey(keyPath string) (*atcrypto.PrivateKeyK256, error) { 140 + key, err := GenerateOrLoadKey(keyPath, KeyTypeK256) 141 + if err != nil { 142 + return nil, err 143 + } 144 + 145 + k256Key, ok := key.(*atcrypto.PrivateKeyK256) 146 + if !ok { 147 + return nil, fmt.Errorf("expected K-256 key, got different type") 148 + } 149 + 150 + return k256Key, nil 151 + } 152 + 153 + // GenerateKeyID generates a stable key ID from a P256 public key 154 + // Uses the first 8 characters of the hex-encoded SHA256 hash of the public key bytes 155 + func GenerateKeyID(privateKey *atcrypto.PrivateKeyP256) (string, error) { 156 + // Get public key 157 + pubKey, err := privateKey.PublicKey() 158 + if err != nil { 159 + return "", fmt.Errorf("failed to get public key: %w", err) 160 + } 161 + 162 + // Get public key bytes 163 + pubKeyBytes := pubKey.Bytes() 164 + 165 + // Hash public key bytes 166 + hash := sha256.Sum256(pubKeyBytes) 167 + 168 + // Return first 8 characters of hex-encoded hash 169 + return hex.EncodeToString(hash[:])[:8], nil 170 + } 171 + 172 + // PrivateKeyToMultibase converts a P256 private key to multibase format 173 + // Required by indigo's SetClientSecret() API 174 + func PrivateKeyToMultibase(key *atcrypto.PrivateKeyP256) string { 175 + return key.Multibase() 176 + } 177 + 178 + // MultibaseToPrivateKey parses a multibase-encoded P256 private key 179 + func MultibaseToPrivateKey(encoded string) (*atcrypto.PrivateKeyP256, error) { 180 + // ParsePrivateMultibase returns PrivateKeyExportable interface 181 + key, err := atcrypto.ParsePrivateMultibase(encoded) 182 + if err != nil { 183 + return nil, fmt.Errorf("failed to parse multibase key: %w", err) 184 + } 185 + 186 + // Type assert to P256 key 187 + p256Key, ok := key.(*atcrypto.PrivateKeyP256) 188 + if !ok { 189 + return nil, fmt.Errorf("expected P-256 key, got different key type") 190 + } 191 + 192 + return p256Key, nil 193 + }
+2 -2
pkg/auth/oauth/refresher_test.go
··· 13 13 t.Fatalf("NewFileStore() error = %v", err) 14 14 } 15 15 16 - app, err := NewApp("http://localhost:5000", store, "*", false) 16 + app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry") 17 17 if err != nil { 18 18 t.Fatalf("NewApp() error = %v", err) 19 19 } ··· 45 45 t.Fatalf("NewFileStore() error = %v", err) 46 46 } 47 47 48 - app, err := NewApp("http://localhost:5000", store, "*", false) 48 + app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry") 49 49 if err != nil { 50 50 t.Fatalf("NewApp() error = %v", err) 51 51 }
+12 -12
pkg/auth/oauth/server_test.go
··· 19 19 t.Fatalf("NewFileStore() error = %v", err) 20 20 } 21 21 22 - app, err := NewApp("http://localhost:5000", store, "*", false) 22 + app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry") 23 23 if err != nil { 24 24 t.Fatalf("NewApp() error = %v", err) 25 25 } ··· 43 43 t.Fatalf("NewFileStore() error = %v", err) 44 44 } 45 45 46 - app, err := NewApp("http://localhost:5000", store, "*", false) 46 + app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry") 47 47 if err != nil { 48 48 t.Fatalf("NewApp() error = %v", err) 49 49 } ··· 66 66 t.Fatalf("NewFileStore() error = %v", err) 67 67 } 68 68 69 - app, err := NewApp("http://localhost:5000", store, "*", false) 69 + app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry") 70 70 if err != nil { 71 71 t.Fatalf("NewApp() error = %v", err) 72 72 } ··· 92 92 t.Fatalf("NewFileStore() error = %v", err) 93 93 } 94 94 95 - app, err := NewApp("http://localhost:5000", store, "*", false) 95 + app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry") 96 96 if err != nil { 97 97 t.Fatalf("NewApp() error = %v", err) 98 98 } ··· 155 155 t.Fatalf("NewFileStore() error = %v", err) 156 156 } 157 157 158 - app, err := NewApp("http://localhost:5000", store, "*", false) 158 + app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry") 159 159 if err != nil { 160 160 t.Fatalf("NewApp() error = %v", err) 161 161 } ··· 182 182 t.Fatalf("NewFileStore() error = %v", err) 183 183 } 184 184 185 - app, err := NewApp("http://localhost:5000", store, "*", false) 185 + app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry") 186 186 if err != nil { 187 187 t.Fatalf("NewApp() error = %v", err) 188 188 } ··· 211 211 t.Fatalf("NewFileStore() error = %v", err) 212 212 } 213 213 214 - app, err := NewApp("http://localhost:5000", store, "*", false) 214 + app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry") 215 215 if err != nil { 216 216 t.Fatalf("NewApp() error = %v", err) 217 217 } ··· 238 238 t.Fatalf("NewFileStore() error = %v", err) 239 239 } 240 240 241 - app, err := NewApp("http://localhost:5000", store, "*", false) 241 + app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry") 242 242 if err != nil { 243 243 t.Fatalf("NewApp() error = %v", err) 244 244 } ··· 270 270 t.Fatalf("NewFileStore() error = %v", err) 271 271 } 272 272 273 - app, err := NewApp("http://localhost:5000", store, "*", false) 273 + app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry") 274 274 if err != nil { 275 275 t.Fatalf("NewApp() error = %v", err) 276 276 } ··· 314 314 t.Fatalf("NewFileStore() error = %v", err) 315 315 } 316 316 317 - app, err := NewApp("http://localhost:5000", store, "*", false) 317 + app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry") 318 318 if err != nil { 319 319 t.Fatalf("NewApp() error = %v", err) 320 320 } ··· 343 343 t.Fatalf("NewFileStore() error = %v", err) 344 344 } 345 345 346 - app, err := NewApp("http://localhost:5000", store, "*", false) 346 + app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry") 347 347 if err != nil { 348 348 t.Fatalf("NewApp() error = %v", err) 349 349 } ··· 377 377 t.Fatalf("NewFileStore() error = %v", err) 378 378 } 379 379 380 - app, err := NewApp("http://localhost:5000", store, "*", false) 380 + app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry") 381 381 if err != nil { 382 382 t.Fatalf("NewApp() error = %v", err) 383 383 }
+2 -1
pkg/hold/oci/xrpc_test.go
··· 14 14 "testing" 15 15 16 16 "atcr.io/pkg/atproto" 17 + "atcr.io/pkg/auth/oauth" 17 18 "atcr.io/pkg/hold/pds" 18 19 "atcr.io/pkg/s3" 19 20 "github.com/distribution/distribution/v3/registry/storage/driver/factory" ··· 37 38 38 39 // Generate one signing key to be reused across all tests 39 40 sharedTestKeyPath = filepath.Join(tmpDir, "shared-signing-key") 40 - privateKey, err := pds.GenerateOrLoadKey(sharedTestKeyPath) 41 + privateKey, err := oauth.GenerateOrLoadPDSKey(sharedTestKeyPath) 41 42 if err != nil { 42 43 panic(fmt.Sprintf("Failed to generate shared signing key: %v", err)) 43 44 }
-78
pkg/hold/pds/keys.go
··· 1 - package pds 2 - 3 - import ( 4 - "fmt" 5 - "log/slog" 6 - "os" 7 - "path/filepath" 8 - 9 - "github.com/bluesky-social/indigo/atproto/atcrypto" 10 - ) 11 - 12 - // GenerateOrLoadKey generates a new K256 key pair or loads an existing one 13 - func GenerateOrLoadKey(keyPath string) (*atcrypto.PrivateKeyK256, error) { 14 - // Ensure directory exists 15 - dir := filepath.Dir(keyPath) 16 - if err := os.MkdirAll(dir, 0700); err != nil { 17 - return nil, fmt.Errorf("failed to create key directory: %w", err) 18 - } 19 - 20 - // Check if key already exists 21 - if _, err := os.Stat(keyPath); err == nil { 22 - // Key exists, load it 23 - return loadKey(keyPath) 24 - } 25 - 26 - // Key doesn't exist, generate new one 27 - return generateKey(keyPath) 28 - } 29 - 30 - // generateKey creates a new K256 (secp256k1) key pair using indigo's atcrypto 31 - func generateKey(keyPath string) (*atcrypto.PrivateKeyK256, error) { 32 - // Generate K256 key (secp256k1) using indigo 33 - privateKey, err := atcrypto.GeneratePrivateKeyK256() 34 - if err != nil { 35 - return nil, fmt.Errorf("failed to generate key: %w", err) 36 - } 37 - 38 - // Serialize key to bytes 39 - keyBytes := privateKey.Bytes() 40 - 41 - // Write to file with restrictive permissions 42 - if err := os.WriteFile(keyPath, keyBytes, 0600); err != nil { 43 - return nil, fmt.Errorf("failed to write key file: %w", err) 44 - } 45 - 46 - slog.Info("Generated new K-256 signing key", "path", keyPath) 47 - return privateKey, nil 48 - } 49 - 50 - // loadKey loads an existing private key from disk 51 - func loadKey(keyPath string) (*atcrypto.PrivateKeyK256, error) { 52 - // Read key bytes 53 - keyBytes, err := os.ReadFile(keyPath) 54 - if err != nil { 55 - return nil, fmt.Errorf("failed to read key file: %w", err) 56 - } 57 - 58 - // Try to parse as K256 private key 59 - privateKey, err := atcrypto.ParsePrivateBytesK256(keyBytes) 60 - if err != nil { 61 - // Check if this is an old P-256 PEM key (migration) 62 - if isPEMFormat(keyBytes) { 63 - slog.Warn("Detected old P-256 key, replacing with K-256") 64 - // Generate new K-256 key (overwrites old P-256) 65 - return generateKey(keyPath) 66 - } 67 - 68 - return nil, fmt.Errorf("failed to parse private key: %w", err) 69 - } 70 - 71 - slog.Info("Loaded existing K-256 signing key", "path", keyPath) 72 - return privateKey, nil 73 - } 74 - 75 - // isPEMFormat checks if bytes are in PEM format (old P-256 keys) 76 - func isPEMFormat(data []byte) bool { 77 - return len(data) > 10 && string(data[:5]) == "-----" 78 - }
-437
pkg/hold/pds/keys_test.go
··· 1 - package pds 2 - 3 - import ( 4 - "bytes" 5 - "os" 6 - "path/filepath" 7 - "testing" 8 - 9 - "github.com/bluesky-social/indigo/atproto/atcrypto" 10 - ) 11 - 12 - // TestGenerateOrLoadKey_Generate tests generating a new key 13 - func TestGenerateOrLoadKey_Generate(t *testing.T) { 14 - tmpDir := t.TempDir() 15 - keyPath := filepath.Join(tmpDir, "test-key") 16 - 17 - // Verify key doesn't exist yet 18 - if _, err := os.Stat(keyPath); !os.IsNotExist(err) { 19 - t.Fatal("Expected key file to not exist") 20 - } 21 - 22 - // Generate key 23 - key, err := GenerateOrLoadKey(keyPath) 24 - if err != nil { 25 - t.Fatalf("GenerateOrLoadKey failed: %v", err) 26 - } 27 - 28 - if key == nil { 29 - t.Fatal("Expected non-nil key") 30 - } 31 - 32 - // Verify key file was created 33 - if _, err := os.Stat(keyPath); os.IsNotExist(err) { 34 - t.Error("Expected key file to be created") 35 - } 36 - 37 - // Verify key file has restrictive permissions (0600) 38 - fileInfo, err := os.Stat(keyPath) 39 - if err != nil { 40 - t.Fatalf("Failed to stat key file: %v", err) 41 - } 42 - 43 - perm := fileInfo.Mode().Perm() 44 - expectedPerm := os.FileMode(0600) 45 - if perm != expectedPerm { 46 - t.Errorf("Expected key file permissions %o, got %o", expectedPerm, perm) 47 - } 48 - 49 - // Verify key can sign data 50 - testData := []byte("test data") 51 - signature, err := key.HashAndSign(testData) 52 - if err != nil { 53 - t.Fatalf("Failed to sign with generated key: %v", err) 54 - } 55 - 56 - if len(signature) == 0 { 57 - t.Error("Expected non-empty signature") 58 - } 59 - 60 - // Verify signature 61 - pubKey, err := key.PublicKey() 62 - if err != nil { 63 - t.Fatalf("Failed to get public key: %v", err) 64 - } 65 - 66 - err = pubKey.HashAndVerify(testData, signature) 67 - if err != nil { 68 - t.Fatalf("Failed to verify signature: %v", err) 69 - } 70 - } 71 - 72 - // TestGenerateOrLoadKey_Load tests loading an existing key 73 - func TestGenerateOrLoadKey_Load(t *testing.T) { 74 - tmpDir := t.TempDir() 75 - keyPath := filepath.Join(tmpDir, "test-key") 76 - 77 - // Generate initial key 78 - key1, err := GenerateOrLoadKey(keyPath) 79 - if err != nil { 80 - t.Fatalf("GenerateOrLoadKey failed on first call: %v", err) 81 - } 82 - 83 - // Get key bytes for comparison 84 - key1Bytes := key1.Bytes() 85 - 86 - // Load the same key again 87 - key2, err := GenerateOrLoadKey(keyPath) 88 - if err != nil { 89 - t.Fatalf("GenerateOrLoadKey failed on second call: %v", err) 90 - } 91 - 92 - // Get key2 bytes 93 - key2Bytes := key2.Bytes() 94 - 95 - // Verify keys are identical 96 - if len(key1Bytes) != len(key2Bytes) { 97 - t.Fatalf("Key byte length mismatch: %d vs %d", len(key1Bytes), len(key2Bytes)) 98 - } 99 - 100 - for i := range key1Bytes { 101 - if key1Bytes[i] != key2Bytes[i] { 102 - t.Errorf("Key byte mismatch at position %d: %x vs %x", i, key1Bytes[i], key2Bytes[i]) 103 - } 104 - } 105 - 106 - // Verify both keys produce same signature for same data 107 - testData := []byte("consistent test data") 108 - 109 - sig1, err := key1.HashAndSign(testData) 110 - if err != nil { 111 - t.Fatalf("Failed to sign with key1: %v", err) 112 - } 113 - 114 - // Verify sig1 with key2's public key 115 - pubKey2, err := key2.PublicKey() 116 - if err != nil { 117 - t.Fatalf("Failed to get public key from key2: %v", err) 118 - } 119 - 120 - err = pubKey2.HashAndVerify(testData, sig1) 121 - if err != nil { 122 - t.Error("Signature from key1 should verify with key2 (they're the same key)") 123 - } 124 - } 125 - 126 - // TestGenerateOrLoadKey_P256Migration tests migrating from old P-256 keys 127 - func TestGenerateOrLoadKey_P256Migration(t *testing.T) { 128 - tmpDir := t.TempDir() 129 - keyPath := filepath.Join(tmpDir, "old-pem-key") 130 - 131 - // Create a fake PEM file (old P-256 format) 132 - pemContent := []byte(`-----BEGIN EC PRIVATE KEY----- 133 - MHcCAQEEIFakeKeyDataHereThisIsNotARealKeyButHasPEMFormat 134 - -----END EC PRIVATE KEY-----`) 135 - 136 - err := os.WriteFile(keyPath, pemContent, 0600) 137 - if err != nil { 138 - t.Fatalf("Failed to write fake PEM key: %v", err) 139 - } 140 - 141 - // Verify file exists and is in PEM format 142 - data, err := os.ReadFile(keyPath) 143 - if err != nil { 144 - t.Fatalf("Failed to read key file: %v", err) 145 - } 146 - 147 - if !isPEMFormat(data) { 148 - t.Fatal("Expected key file to be in PEM format") 149 - } 150 - 151 - // Call GenerateOrLoadKey - should detect PEM and generate new K-256 key 152 - key, err := GenerateOrLoadKey(keyPath) 153 - if err != nil { 154 - t.Fatalf("GenerateOrLoadKey failed during P-256 migration: %v", err) 155 - } 156 - 157 - if key == nil { 158 - t.Fatal("Expected non-nil key after migration") 159 - } 160 - 161 - // Verify key file was replaced (no longer PEM) 162 - newData, err := os.ReadFile(keyPath) 163 - if err != nil { 164 - t.Fatalf("Failed to read new key file: %v", err) 165 - } 166 - 167 - if isPEMFormat(newData) { 168 - t.Error("Expected key file to no longer be in PEM format after migration") 169 - } 170 - 171 - // Verify new key is K-256 and works 172 - testData := []byte("test after migration") 173 - signature, err := key.HashAndSign(testData) 174 - if err != nil { 175 - t.Fatalf("Failed to sign with migrated key: %v", err) 176 - } 177 - 178 - pubKey, err := key.PublicKey() 179 - if err != nil { 180 - t.Fatalf("Failed to get public key: %v", err) 181 - } 182 - 183 - err = pubKey.HashAndVerify(testData, signature) 184 - if err != nil { 185 - t.Fatalf("Failed to verify signature from migrated key: %v", err) 186 - } 187 - } 188 - 189 - // TestKeyPersistence tests that key bytes survive save/load cycle 190 - func TestKeyPersistence(t *testing.T) { 191 - tmpDir := t.TempDir() 192 - keyPath := filepath.Join(tmpDir, "persist-key") 193 - 194 - // Generate key 195 - originalKey, err := GenerateOrLoadKey(keyPath) 196 - if err != nil { 197 - t.Fatalf("GenerateOrLoadKey failed: %v", err) 198 - } 199 - 200 - // Get original key bytes 201 - originalBytes := originalKey.Bytes() 202 - 203 - // Read key file directly 204 - fileBytes, err := os.ReadFile(keyPath) 205 - if err != nil { 206 - t.Fatalf("Failed to read key file: %v", err) 207 - } 208 - 209 - // Verify file bytes match key bytes 210 - if len(fileBytes) != len(originalBytes) { 211 - t.Fatalf("File byte length mismatch: %d vs %d", len(fileBytes), len(originalBytes)) 212 - } 213 - 214 - for i := range originalBytes { 215 - if fileBytes[i] != originalBytes[i] { 216 - t.Errorf("File byte mismatch at position %d: %x vs %x", i, fileBytes[i], originalBytes[i]) 217 - } 218 - } 219 - 220 - // Parse key directly from file bytes 221 - parsedKey, err := atcrypto.ParsePrivateBytesK256(fileBytes) 222 - if err != nil { 223 - t.Fatalf("Failed to parse key from file bytes: %v", err) 224 - } 225 - 226 - // Verify parsed key matches original 227 - parsedBytes := parsedKey.Bytes() 228 - if len(parsedBytes) != len(originalBytes) { 229 - t.Fatalf("Parsed key byte length mismatch: %d vs %d", len(parsedBytes), len(originalBytes)) 230 - } 231 - 232 - for i := range originalBytes { 233 - if parsedBytes[i] != originalBytes[i] { 234 - t.Errorf("Parsed key byte mismatch at position %d: %x vs %x", i, parsedBytes[i], originalBytes[i]) 235 - } 236 - } 237 - } 238 - 239 - // TestGenerateOrLoadKey_DirectoryCreation tests that parent directory is created 240 - func TestGenerateOrLoadKey_DirectoryCreation(t *testing.T) { 241 - tmpDir := t.TempDir() 242 - keyPath := filepath.Join(tmpDir, "nested", "dir", "test-key") 243 - 244 - // Verify nested directories don't exist 245 - nestedDir := filepath.Join(tmpDir, "nested", "dir") 246 - if _, err := os.Stat(nestedDir); !os.IsNotExist(err) { 247 - t.Fatal("Expected nested directory to not exist") 248 - } 249 - 250 - // Generate key (should create directories) 251 - key, err := GenerateOrLoadKey(keyPath) 252 - if err != nil { 253 - t.Fatalf("GenerateOrLoadKey failed: %v", err) 254 - } 255 - 256 - if key == nil { 257 - t.Fatal("Expected non-nil key") 258 - } 259 - 260 - // Verify directories were created 261 - if _, err := os.Stat(nestedDir); os.IsNotExist(err) { 262 - t.Error("Expected nested directory to be created") 263 - } 264 - 265 - // Verify key file exists 266 - if _, err := os.Stat(keyPath); os.IsNotExist(err) { 267 - t.Error("Expected key file to be created") 268 - } 269 - 270 - // Verify directory has restrictive permissions (0700) 271 - dirInfo, err := os.Stat(nestedDir) 272 - if err != nil { 273 - t.Fatalf("Failed to stat directory: %v", err) 274 - } 275 - 276 - dirPerm := dirInfo.Mode().Perm() 277 - expectedDirPerm := os.FileMode(0700) 278 - if dirPerm != expectedDirPerm { 279 - t.Errorf("Expected directory permissions %o, got %o", expectedDirPerm, dirPerm) 280 - } 281 - } 282 - 283 - // TestIsPEMFormat tests the PEM format detection 284 - func TestIsPEMFormat(t *testing.T) { 285 - tests := []struct { 286 - name string 287 - data []byte 288 - expected bool 289 - }{ 290 - { 291 - name: "Valid PEM", 292 - data: []byte("-----BEGIN EC PRIVATE KEY-----\ndata\n-----END EC PRIVATE KEY-----"), 293 - expected: true, 294 - }, 295 - { 296 - name: "Valid PEM (RSA)", 297 - data: []byte("-----BEGIN RSA PRIVATE KEY-----\ndata\n-----END RSA PRIVATE KEY-----"), 298 - expected: true, 299 - }, 300 - { 301 - name: "Binary data", 302 - data: []byte{0x01, 0x02, 0x03, 0x04, 0x05}, 303 - expected: false, 304 - }, 305 - { 306 - name: "Empty data", 307 - data: []byte{}, 308 - expected: false, 309 - }, 310 - { 311 - name: "Short data", 312 - data: []byte("----"), 313 - expected: false, 314 - }, 315 - { 316 - name: "Almost PEM (missing dashes)", 317 - data: []byte("----BEGIN KEY-----"), 318 - expected: false, 319 - }, 320 - } 321 - 322 - for _, tt := range tests { 323 - t.Run(tt.name, func(t *testing.T) { 324 - result := isPEMFormat(tt.data) 325 - if result != tt.expected { 326 - t.Errorf("Expected isPEMFormat=%v, got %v", tt.expected, result) 327 - } 328 - }) 329 - } 330 - } 331 - 332 - // TestGenerateKey_UniqueKeys tests that each generated key is unique 333 - func TestGenerateKey_UniqueKeys(t *testing.T) { 334 - tmpDir := t.TempDir() 335 - 336 - // Generate multiple keys 337 - var keyBytes [][]byte 338 - for i := 0; i < 5; i++ { 339 - keyPath := filepath.Join(tmpDir, "key-"+string(rune('a'+i))) 340 - key, err := GenerateOrLoadKey(keyPath) 341 - if err != nil { 342 - t.Fatalf("GenerateOrLoadKey failed for key %d: %v", i, err) 343 - } 344 - keyBytes = append(keyBytes, key.Bytes()) 345 - } 346 - 347 - // Verify all keys are different 348 - for i := 0; i < len(keyBytes); i++ { 349 - for j := i + 1; j < len(keyBytes); j++ { 350 - // Keys should be different 351 - identical := true 352 - if len(keyBytes[i]) != len(keyBytes[j]) { 353 - identical = false 354 - } else { 355 - for k := range keyBytes[i] { 356 - if keyBytes[i][k] != keyBytes[j][k] { 357 - identical = false 358 - break 359 - } 360 - } 361 - } 362 - 363 - if identical { 364 - t.Errorf("Keys %d and %d are identical (expected unique keys)", i, j) 365 - } 366 - } 367 - } 368 - } 369 - 370 - // TestLoadKey_InvalidFormat tests loading key with invalid format 371 - func TestLoadKey_InvalidFormat(t *testing.T) { 372 - tmpDir := t.TempDir() 373 - keyPath := filepath.Join(tmpDir, "invalid-key") 374 - 375 - // Write invalid data (not a valid K-256 key and not PEM) 376 - invalidData := []byte("This is not a valid key format at all") 377 - err := os.WriteFile(keyPath, invalidData, 0600) 378 - if err != nil { 379 - t.Fatalf("Failed to write invalid key: %v", err) 380 - } 381 - 382 - // Try to load (should fail with parse error, then try to generate new key) 383 - // Since it's not PEM, it will try to parse as K-256 and fail, 384 - // then NOT migrate (migration only happens for PEM), so it should error 385 - _, err = GenerateOrLoadKey(keyPath) 386 - if err == nil { 387 - t.Fatal("Expected error when loading invalid key format") 388 - } 389 - 390 - // Error should mention parsing failure 391 - if err != nil && err.Error() == "" { 392 - t.Error("Expected non-empty error message") 393 - } 394 - } 395 - 396 - // TestGenerateOrLoadKey_CorruptedKey tests behavior with corrupted key file 397 - func TestGenerateOrLoadKey_CorruptedKey(t *testing.T) { 398 - tmpDir := t.TempDir() 399 - keyPath := filepath.Join(tmpDir, "corrupted-key") 400 - 401 - // Generate valid key first 402 - key1, err := GenerateOrLoadKey(keyPath) 403 - if err != nil { 404 - t.Fatalf("GenerateOrLoadKey failed: %v", err) 405 - } 406 - 407 - originalBytes := key1.Bytes() 408 - 409 - // Corrupt the key file (flip some bits in the middle) 410 - corruptedBytes := make([]byte, len(originalBytes)) 411 - copy(corruptedBytes, originalBytes) 412 - if len(corruptedBytes) > 10 { 413 - corruptedBytes[5] ^= 0xFF 414 - corruptedBytes[10] ^= 0xFF 415 - } 416 - 417 - err = os.WriteFile(keyPath, corruptedBytes, 0600) 418 - if err != nil { 419 - t.Fatalf("Failed to write corrupted key: %v", err) 420 - } 421 - 422 - // Try to load corrupted key 423 - // Note: K256 keys are flexible, so slightly corrupted keys might still be valid 424 - // We just verify that it either loads successfully or returns an error 425 - key2, err := GenerateOrLoadKey(keyPath) 426 - if err != nil { 427 - // Expected - corrupted key failed to load 428 - t.Logf("Corrupted key failed to load as expected: %v", err) 429 - return 430 - } 431 - 432 - // If it loaded, verify it's different from the original 433 - key2Bytes := key2.Bytes() 434 - if bytes.Equal(originalBytes, key2Bytes) { 435 - t.Error("Corrupted key loaded with same bytes as original (unexpected)") 436 - } 437 - }
+2 -1
pkg/hold/pds/server.go
··· 9 9 "strings" 10 10 11 11 "atcr.io/pkg/atproto" 12 + "atcr.io/pkg/auth/oauth" 12 13 "github.com/bluesky-social/indigo/atproto/atcrypto" 13 14 "github.com/bluesky-social/indigo/carstore" 14 15 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 44 45 // NewHoldPDS creates or opens a hold PDS with SQLite carstore 45 46 func NewHoldPDS(ctx context.Context, did, publicURL, dbPath, keyPath string, enableBlueskyPosts bool) (*HoldPDS, error) { 46 47 // Generate or load signing key 47 - signingKey, err := GenerateOrLoadKey(keyPath) 48 + signingKey, err := oauth.GenerateOrLoadPDSKey(keyPath) 48 49 if err != nil { 49 50 return nil, fmt.Errorf("failed to initialize signing key: %w", err) 50 51 }
+2 -1
pkg/hold/pds/status_test.go
··· 12 12 "time" 13 13 14 14 "atcr.io/pkg/atproto" 15 + "atcr.io/pkg/auth/oauth" 15 16 "atcr.io/pkg/s3" 16 17 bsky "github.com/bluesky-social/indigo/api/bsky" 17 18 ) ··· 265 266 266 267 // Generate one signing key to be reused across all tests in the package 267 268 sharedTestKeyPath = filepath.Join(tmpDir, "shared-signing-key") 268 - privateKey, err := GenerateOrLoadKey(sharedTestKeyPath) 269 + privateKey, err := oauth.GenerateOrLoadPDSKey(sharedTestKeyPath) 269 270 if err != nil { 270 271 panic(fmt.Sprintf("Failed to generate shared signing key: %v", err)) 271 272 }