A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
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 }