use confidential oauth in production

This commit is contained in:
Evan Jarrett
2025-10-29 12:06:47 -05:00
parent c7fdb748ae
commit 6793ba6a50
19 changed files with 857 additions and 622 deletions
+10
View File
@@ -49,6 +49,16 @@ ATCR_DEFAULT_HOLD_DID=did:web:127.0.0.1:8080
# JWT token expiration in seconds (default: 300 = 5 minutes)
# ATCR_TOKEN_EXPIRATION=300
# Path to OAuth client P-256 signing key (auto-generated on first run)
# Used for confidential OAuth client authentication (production only)
# Localhost deployments always use public OAuth clients (no key needed)
# Default: /var/lib/atcr/oauth/client.key
# ATCR_OAUTH_KEY_PATH=/var/lib/atcr/oauth/client.key
# OAuth client display name (shown in authorization screens)
# Default: AT Container Registry
# ATCR_CLIENT_NAME=AT Container Registry
# ==============================================================================
# UI Configuration
# ==============================================================================
+30 -8
View File
@@ -221,31 +221,48 @@ ATCR implements the full ATProto OAuth specification with mandatory security fea
**Key Components** (`pkg/auth/oauth/`):
1. **Client** (`client.go`) - Core OAuth client with encapsulated configuration
- Constructor: `NewClient(baseURL)` - accepts base URL, derives client ID/redirect URI
- `NewClientWithKey(baseURL, dpopKey)` - for token refresh with stored DPoP key
- `ClientID()` - computes localhost vs production client ID dynamically
- Uses indigo's `NewLocalhostConfig()` for localhost (public client)
- Uses `NewPublicConfig()` for production base (upgraded to confidential if key provided)
- `RedirectURI()` - returns `baseURL + "/auth/oauth/callback"`
- `GetDefaultScopes()` - returns ATCR registry scopes
- `GetConfigRef()` - returns mutable config for `SetClientSecret()` calls
- All OAuth flows (authorization, token exchange, refresh) in one place
2. **Token Storage** (`store.go`) - Persists OAuth sessions for AppView
- File-based storage in `/var/lib/atcr/refresh-tokens.json` (AppView)
2. **Keys** (`keys.go`) - P-256 key management for confidential clients
- `GenerateOrLoadClientKey()` - generates or loads P-256 key from disk
- Follows hold service pattern: auto-generation, 0600 permissions, /var/lib/atcr/oauth/
- `GenerateKeyID()` - derives key ID from public key hash
- `PrivateKeyToMultibase()` - converts key for `SetClientSecret()` API
- **Key type:** P-256 (ES256) for OAuth standard compatibility (not K-256 like PDS keys)
3. **Token Storage** (`store.go`) - Persists OAuth sessions for AppView
- SQLite-backed storage in UI database (not file-based)
- Client uses `~/.atcr/oauth-token.json` (credential helper)
3. **Refresher** (`refresher.go`) - Token refresh manager for AppView
4. **Refresher** (`refresher.go`) - Token refresh manager for AppView
- Caches OAuth sessions with automatic token refresh (handled by indigo library)
- Per-DID locking prevents concurrent refresh races
- Uses Client methods for consistency
4. **Server** (`server.go`) - OAuth authorization endpoints for AppView
5. **Server** (`server.go`) - OAuth authorization endpoints for AppView
- `GET /auth/oauth/authorize` - starts OAuth flow
- `GET /auth/oauth/callback` - handles OAuth callback
- Uses Client methods for authorization and token exchange
5. **Interactive Flow** (`interactive.go`) - Reusable OAuth flow for CLI tools
6. **Interactive Flow** (`interactive.go`) - Reusable OAuth flow for CLI tools
- Used by credential helper and hold service registration
- Two-phase callback setup ensures PAR metadata availability
**Client Configuration:**
- **Localhost:** Always public client (no client authentication)
- Client ID: `http://localhost?redirect_uri=...&scope=...` (query-based)
- No P-256 key generation
- **Production:** Confidential client with P-256 private key (if key exists)
- Client ID: `{baseURL}/client-metadata.json` (metadata endpoint)
- Key path: `/var/lib/atcr/oauth/client.key` (auto-generated on first run)
- Key algorithm: ES256 (P-256, not K-256)
- Upgraded via `config.SetClientSecret(key, keyID)`
**Authentication Flow:**
```
1. User configures Docker to use the credential helper (adds to config.json)
@@ -280,6 +297,11 @@ Later (subsequent docker push):
- No trust in client-provided identity information
- DPoP binds tokens to specific client key
- 15-minute token expiry for registry JWTs
- **Confidential clients** (production): Client authentication via P-256 private key JWT assertion
- Prevents client impersonation attacks
- Key stored in `/var/lib/atcr/oauth/client.key` with 0600 permissions
- Automatically generated on first run
- **Public clients** (localhost): No client authentication (development only)
### Key Components
+22 -4
View File
@@ -119,8 +119,8 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution and transition:generic scope")
}
// Create OAuth app (indigo client)
oauthApp, err := oauth.NewApp(baseURL, oauthStore, defaultHoldDID, testMode)
// Create OAuth app (automatically configures confidential client for production)
oauthApp, err := oauth.NewApp(baseURL, oauthStore, defaultHoldDID, cfg.Server.OAuthKeyPath, cfg.Server.ClientName)
if err != nil {
return fmt.Errorf("failed to create OAuth app: %w", err)
}
@@ -132,7 +132,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
// Invalidate sessions with mismatched scopes on startup
// This ensures all users have the latest required scopes after deployment
desiredScopes := oauth.GetDefaultScopes(defaultHoldDID, testMode)
desiredScopes := oauth.GetDefaultScopes(defaultHoldDID)
invalidatedCount, err := oauthStore.InvalidateSessionsWithMismatchedScopes(context.Background(), desiredScopes)
if err != nil {
slog.Warn("Failed to invalidate sessions with mismatched scopes", "error", err)
@@ -385,9 +385,27 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
config := oauthApp.GetConfig()
metadata := config.ClientMetadata()
// Convert indigo's metadata to map so we can add custom fields
metadataBytes, err := json.Marshal(metadata)
if err != nil {
http.Error(w, "Failed to marshal metadata", http.StatusInternalServerError)
return
}
var metadataMap map[string]interface{}
if err := json.Unmarshal(metadataBytes, &metadataMap); err != nil {
http.Error(w, "Failed to unmarshal metadata", http.StatusInternalServerError)
return
}
// Add custom fields
metadataMap["client_name"] = cfg.Server.ClientName
metadataMap["client_uri"] = cfg.Server.BaseURL
metadataMap["logo_uri"] = cfg.Server.BaseURL + "/web-app-manifest-192x192.png"
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
if err := json.NewEncoder(w).Encode(metadata); err != nil {
if err := json.NewEncoder(w).Encode(metadataMap); err != nil {
http.Error(w, "Failed to encode metadata", http.StatusInternalServerError)
}
})
+4
View File
@@ -151,6 +151,10 @@ S3_ENDPOINT=https://6vmss.upcloudobjects.com
# Default: 300 (5 minutes)
ATCR_TOKEN_EXPIRATION=300
# OAuth client display name (shown in authorization screens)
# Default: AT Container Registry
# ATCR_CLIENT_NAME=AT Container Registry
# Enable web UI
# Default: true
ATCR_UI_ENABLED=true
+99 -35
View File
@@ -130,11 +130,13 @@ if [ -f "deploy/.env.prod.template" ] && [ ! -f "$ATCR_DIR/.env" ]; then
log_warn "IMPORTANT: Edit $ATCR_DIR/.env with your configuration!"
fi
# Create systemd service
log_info "Creating systemd service..."
cat > /etc/systemd/system/atcr.service <<'EOF'
# Create systemd services (caddy, appview, hold)
log_info "Creating systemd services..."
# Caddy service (reverse proxy for both appview and hold)
cat > /etc/systemd/system/atcr-caddy.service <<'EOF'
[Unit]
Description=ATCR Container Registry
Description=ATCR Caddy Reverse Proxy
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
@@ -145,14 +147,76 @@ RemainAfterExit=yes
WorkingDirectory=/opt/atcr
EnvironmentFile=/opt/atcr/.env
# Start containers
ExecStart=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml up -d
# Start caddy container
ExecStart=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml up -d caddy
# Stop containers
ExecStop=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml down
# Stop caddy container
ExecStop=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml stop caddy
# Restart containers
ExecReload=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml restart
# Restart caddy container
ExecReload=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml restart caddy
# Always restart on failure
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
# AppView service (registry + web UI)
cat > /etc/systemd/system/atcr-appview.service <<'EOF'
[Unit]
Description=ATCR AppView (Registry + Web UI)
Requires=docker.service atcr-caddy.service
After=docker.service network-online.target atcr-caddy.service
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/atcr
EnvironmentFile=/opt/atcr/.env
# Start appview container
ExecStart=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml up -d atcr-appview
# Stop appview container
ExecStop=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml stop atcr-appview
# Restart appview container
ExecReload=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml restart atcr-appview
# Always restart on failure
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
# Hold service (storage backend)
cat > /etc/systemd/system/atcr-hold.service <<'EOF'
[Unit]
Description=ATCR Hold (Storage Service)
Requires=docker.service atcr-caddy.service
After=docker.service network-online.target atcr-caddy.service
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/atcr
EnvironmentFile=/opt/atcr/.env
# Start hold container
ExecStart=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml up -d atcr-hold
# Stop hold container
ExecStop=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml stop atcr-hold
# Restart hold container
ExecReload=/usr/bin/docker compose -f /opt/atcr/deploy/docker-compose.prod.yml restart atcr-hold
# Always restart on failure
Restart=on-failure
@@ -166,10 +230,12 @@ EOF
log_info "Reloading systemd daemon..."
systemctl daemon-reload
# Enable service (but don't start yet - user needs to configure .env)
systemctl enable atcr.service
# Enable all services (but don't start yet - user needs to configure .env)
systemctl enable atcr-caddy.service
systemctl enable atcr-appview.service
systemctl enable atcr-hold.service
log_info "Systemd service created and enabled"
log_info "Systemd services created and enabled"
# Create helper scripts
log_info "Creating helper scripts..."
@@ -193,14 +259,6 @@ docker compose -f deploy/docker-compose.prod.yml logs -f "$@"
EOF
chmod +x "$ATCR_DIR/logs.sh"
# Script to get hold OAuth URL
cat > "$ATCR_DIR/get-hold-oauth.sh" <<'EOF'
#!/bin/bash
echo "Checking atcr-hold logs for OAuth registration URL..."
docker logs atcr-hold 2>&1 | grep -i "oauth\|authorization\|visit\|http" | tail -20
EOF
chmod +x "$ATCR_DIR/get-hold-oauth.sh"
log_info "Helper scripts created in $ATCR_DIR"
# Print completion message
@@ -241,29 +299,35 @@ echo " CNAME blobs.atcr.io → atcr.us-chi1.upcloudobjects.com (gray cloud
cat <<'EOF'
4. Start ATCR:
systemctl start atcr
4. Start ATCR services:
systemctl start atcr-caddy atcr-appview atcr-hold
5. Complete Hold OAuth registration:
/opt/atcr/get-hold-oauth.sh
Visit the OAuth URL in your browser to authorize the hold service.
6. Check status:
systemctl status atcr
5. Check status:
systemctl status atcr-caddy
systemctl status atcr-appview
systemctl status atcr-hold
docker ps
/opt/atcr/logs.sh
Helper Scripts:
/opt/atcr/rebuild.sh - Rebuild and restart containers
/opt/atcr/logs.sh [service] - View logs (e.g., logs.sh atcr-hold)
/opt/atcr/get-hold-oauth.sh - Get hold OAuth URL
Service Management:
systemctl start atcr - Start ATCR
systemctl stop atcr - Stop ATCR
systemctl restart atcr - Restart ATCR
systemctl status atcr - Check status
systemctl start atcr-caddy - Start Caddy reverse proxy
systemctl start atcr-appview - Start AppView (registry + UI)
systemctl start atcr-hold - Start Hold (storage service)
systemctl stop atcr-appview - Stop AppView only
systemctl stop atcr-hold - Stop Hold only
systemctl stop atcr-caddy - Stop all (stops reverse proxy)
systemctl restart atcr-appview - Restart AppView
systemctl restart atcr-hold - Restart Hold
systemctl status atcr-caddy - Check Caddy status
systemctl status atcr-appview - Check AppView status
systemctl status atcr-hold - Check Hold status
Documentation:
https://tangled.org/@evan.jarrett.net/at-container-registry
+399
View File
@@ -0,0 +1,399 @@
# OAuth Implementation in ATCR
This document describes ATCR's OAuth implementation, which uses the ATProto OAuth specification with DPoP (Demonstrating Proof of Possession) for secure authentication.
## Overview
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.
### Key Features
- **DPoP (RFC 9449)**: Cryptographic proof-of-possession binds tokens to specific client keys
- **PAR (RFC 9126)**: Pushed Authorization Requests for secure server-to-server parameter exchange
- **PKCE (RFC 7636)**: Proof Key for Code Exchange prevents authorization code interception
- **Confidential Clients**: Production deployments use P-256 private keys for client authentication
- **Public Clients**: Development (localhost) uses simpler public client configuration
## Client Types
ATCR supports two OAuth client types depending on the deployment environment:
### Public Clients (Development)
**When:** `baseURL` contains `localhost` or `127.0.0.1`
**Configuration:**
- Client ID: `http://localhost?redirect_uri=...&scope=...` (query-based)
- No client authentication
- Uses indigo's `NewLocalhostConfig()` helper
- DPoP still required for token requests
**Example:**
```go
// Automatically uses public client for localhost
config := oauth.NewClientConfigWithScopes("http://127.0.0.1:5000", scopes)
```
### Confidential Clients (Production)
**When:** `baseURL` is a public domain (not localhost)
**Configuration:**
- Client ID: `{baseURL}/client-metadata.json` (metadata endpoint)
- Client authentication: P-256 (ES256) private key JWT assertion
- Private key stored at `/var/lib/atcr/oauth/client.key`
- Auto-generated on first run with 0600 permissions
- Upgraded via `config.SetClientSecret(privateKey, keyID)`
**Example:**
```go
// 1. Create base config (public)
config := oauth.NewClientConfigWithScopes("https://atcr.io", scopes)
// 2. Load or generate P-256 key
privateKey, err := oauth.GenerateOrLoadClientKey("/var/lib/atcr/oauth/client.key")
// 3. Generate key ID
keyID, err := oauth.GenerateKeyID(privateKey)
// 4. Upgrade to confidential
err = config.SetClientSecret(privateKey, keyID)
```
## Key Management
### P-256 Key Generation
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.
**Why P-256?**
- Standard OAuth/OIDC key algorithm
- Widely supported by authorization servers
- Compatible with indigo's `SetClientSecret()` API
**Key Storage:**
- Default path: `/var/lib/atcr/oauth/client.key`
- Configurable via: `ATCR_OAUTH_KEY_PATH` environment variable
- File permissions: `0600` (owner read/write only)
- Directory permissions: `0700` (owner access only)
- Format: Raw binary bytes (not PEM)
**Key Lifecycle:**
1. On first production startup, AppView checks for key at configured path
2. If missing, generates new P-256 key using `atcrypto.GeneratePrivateKeyP256()`
3. Saves raw key bytes to disk with restrictive permissions
4. Logs generation event: `"Generated new P-256 OAuth client key"`
5. On subsequent startups, loads existing key
6. Logs load event: `"Loaded existing P-256 OAuth client key"`
**Key Rotation:**
To rotate the OAuth client key:
1. Stop the AppView service
2. Delete or rename the existing key file
3. Restart AppView (new key will be generated automatically)
4. Note: Active OAuth sessions may need re-authentication
### Key ID Generation
The key ID is derived from the public key for stable identification:
```go
func GenerateKeyID(privateKey *atcrypto.PrivateKeyP256) (string, error) {
pubKey, _ := privateKey.PublicKey()
pubKeyBytes := pubKey.Bytes()
hash := sha256.Sum256(pubKeyBytes)
return hex.EncodeToString(hash[:])[:8], nil
}
```
This generates an 8-character hex ID from the SHA-256 hash of the public key.
## Authentication Flow
### AppView OAuth Flow
```mermaid
sequenceDiagram
participant User
participant Browser
participant AppView
participant PDS
User->>Browser: docker push atcr.io/alice/myapp
Browser->>AppView: Credential helper redirects
AppView->>PDS: Resolve handle → DID
AppView->>PDS: Discover OAuth metadata
AppView->>PDS: PAR request (with DPoP)
PDS-->>AppView: request_uri
AppView->>Browser: Redirect to authorization page
Browser->>PDS: User authorizes
PDS->>AppView: Authorization code
AppView->>PDS: Token exchange (with DPoP)
PDS-->>AppView: OAuth tokens + DPoP binding
AppView->>User: Issue registry JWT
```
### Key Steps
1. **Identity Resolution**
- AppView resolves handle to DID via `.well-known/atproto-did`
- Resolves DID to PDS endpoint via DID document
2. **OAuth Discovery**
- Fetches `/.well-known/oauth-authorization-server` from PDS
- Extracts `authorization_endpoint`, `token_endpoint`, etc.
3. **Pushed Authorization Request (PAR)**
- AppView sends authorization parameters to PDS token endpoint
- Includes DPoP header with proof JWT
- Receives `request_uri` for authorization
4. **User Authorization**
- User is redirected to PDS authorization page
- User approves application access
- PDS redirects back with authorization code
5. **Token Exchange**
- AppView exchanges code for tokens at PDS token endpoint
- Includes DPoP header with proof JWT
- Receives access token, refresh token (both DPoP-bound)
6. **Token Storage**
- AppView stores OAuth session in SQLite database
- Indigo library manages token refresh automatically
- DPoP key stored with session for future requests
7. **Registry JWT Issuance**
- AppView validates OAuth session
- Issues short-lived registry JWT (15 minutes)
- JWT contains validated DID from PDS session
## DPoP Implementation
### What is DPoP?
DPoP (Demonstrating Proof of Possession) binds OAuth tokens to a specific client key, preventing token theft and replay attacks.
**How it works:**
1. Client generates ephemeral key pair (or uses persistent key)
2. Client includes DPoP proof JWT in Authorization header
3. Proof JWT contains hash of HTTP request details
4. Authorization server validates proof and issues DPoP-bound token
5. Token can only be used with the same client key
### DPoP Headers
Every request to the PDS token endpoint includes a DPoP header:
```http
POST /oauth/token HTTP/1.1
Host: pds.example.com
Content-Type: application/x-www-form-urlencoded
DPoP: eyJhbGciOiJFUzI1NiIsInR5cCI6ImRwb3Arand0IiwiandrIjp7Imt0eSI6Ik...
grant_type=authorization_code&code=...&redirect_uri=...
```
The DPoP header is a signed JWT containing:
- `htm`: HTTP method (e.g., "POST")
- `htu`: HTTP URI (e.g., "https://pds.example.com/oauth/token")
- `jti`: Unique request identifier
- `iat`: Timestamp
- `jwk`: Public key (JWK format)
### Indigo DPoP Management
ATCR uses indigo's built-in DPoP management:
```go
// Indigo automatically handles DPoP
clientApp := oauth.NewClientApp(&config, store)
// All token requests include DPoP automatically
tokens, err := clientApp.ProcessCallback(ctx, params)
// Refresh automatically includes DPoP
session, err := clientApp.ResumeSession(ctx, did, sessionID)
```
Indigo manages:
- DPoP key generation and storage
- DPoP proof JWT creation
- DPoP header inclusion in token requests
- Token binding to DPoP keys
## Client Configuration
### Environment Variables
**ATCR_OAUTH_KEY_PATH**
- Path to OAuth client P-256 signing key
- Default: `/var/lib/atcr/oauth/client.key`
- Auto-generated on first run (production only)
- Format: Raw binary P-256 private key
**ATCR_BASE_URL**
- Public URL of AppView service
- Required for OAuth redirect URIs
- Example: `https://atcr.io`
- Determines client type (public vs confidential)
**ATCR_UI_DATABASE_PATH**
- Path to SQLite database (includes OAuth session storage)
- Default: `/var/lib/atcr/ui.db`
### Client Metadata Endpoint
Production deployments serve OAuth client metadata at `{baseURL}/client-metadata.json`:
```json
{
"client_id": "https://atcr.io/client-metadata.json",
"client_name": "ATCR Registry",
"client_uri": "https://atcr.io",
"redirect_uris": ["https://atcr.io/auth/oauth/callback"],
"scope": "atproto blob:... repo:...",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "private_key_jwt",
"token_endpoint_auth_signing_alg": "ES256",
"jwks": {
"keys": [
{
"kty": "EC",
"crv": "P-256",
"x": "...",
"y": "...",
"kid": "abc12345"
}
]
}
}
```
For localhost, the client ID is query-based and no metadata endpoint is used.
## Scope Management
ATCR requests the following OAuth scopes:
**Base scopes:**
- `atproto`: Basic ATProto access
**Blob scopes (for layer/manifest media types):**
- `blob:application/vnd.oci.image.manifest.v1+json`
- `blob:application/vnd.docker.distribution.manifest.v2+json`
- `blob:application/vnd.oci.image.index.v1+json`
- `blob:application/vnd.docker.distribution.manifest.list.v2+json`
- `blob:application/vnd.cncf.oras.artifact.manifest.v1+json`
**Repo scopes (for ATProto collections):**
- `repo:io.atcr.manifest`: Manifest records
- `repo:io.atcr.tag`: Tag records
- `repo:io.atcr.star`: Star records
- `repo:io.atcr.sailor.profile`: User profile records
**RPC scope:**
- `rpc:com.atproto.repo.getRecord?aud=*`: Read access to any user's records
Scopes are automatically invalidated on startup if they change, forcing users to re-authenticate.
## Security Considerations
### Token Security
**OAuth Tokens (managed by AppView):**
- Stored in SQLite database
- DPoP-bound (cannot be used without client key)
- Automatically refreshed by indigo library
- Used for PDS API requests (manifests, service tokens)
**Registry JWTs (issued to Docker clients):**
- Short-lived (15 minutes)
- Signed by AppView's JWT signing key
- Contain validated DID from OAuth session
- Used for OCI Distribution API requests
### Attack Prevention
**Token Theft:**
- DPoP prevents stolen tokens from being used
- Tokens are bound to specific client key
- Attacker would need both token AND private key
**Client Impersonation:**
- Confidential clients use private key JWT assertion
- Prevents attackers from impersonating AppView
- Public keys published in client metadata JWKS
**Man-in-the-Middle:**
- All OAuth flows use HTTPS in production
- DPoP includes HTTP method and URI in proof
- Prevents replay attacks on different endpoints
**Authorization Code Interception:**
- PKCE prevents code interception attacks
- Code verifier required to exchange code for token
- Protects against malicious redirect URI attacks
## Troubleshooting
### Common Issues
**"Failed to initialize OAuth client key"**
- Check that `/var/lib/atcr/oauth/` directory exists and is writable
- Verify directory permissions are 0700
- Check disk space
**"OAuth session not found"**
- User needs to re-authenticate (session expired or invalidated)
- Check that UI database is accessible
- Verify OAuth session storage is working
**"Invalid DPoP proof"**
- Clock skew between AppView and PDS
- DPoP key mismatch (token was issued with different key)
- Check that indigo library is managing DPoP correctly
**"Client authentication failed"**
- Confidential client key may be corrupted
- Key ID may not match public key
- Try rotating the client key (delete and regenerate)
### Debugging
Enable debug logging to see OAuth flow details:
```bash
export ATCR_LOG_LEVEL=debug
./bin/atcr-appview serve
```
Look for log messages:
- `"Generated new P-256 OAuth client key"` - Key was auto-generated
- `"Loaded existing P-256 OAuth client key"` - Key was loaded from disk
- `"Configured confidential OAuth client"` - Production confidential client active
- `"Localhost detected - using public OAuth client"` - Development public client active
### Testing OAuth Flow
Test OAuth flow manually:
```bash
# 1. Start AppView in debug mode
ATCR_LOG_LEVEL=debug ./bin/atcr-appview serve
# 2. Try docker login
docker login atcr.io
# 3. Check logs for OAuth flow details
# Look for: PAR request, token exchange, DPoP headers, etc.
```
## References
- [ATProto OAuth Specification](https://atproto.com/specs/oauth)
- [RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP)](https://datatracker.ietf.org/doc/html/rfc9449)
- [RFC 9126: OAuth 2.0 Pushed Authorization Requests (PAR)](https://datatracker.ietf.org/doc/html/rfc9126)
- [RFC 7636: Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636)
- [Indigo OAuth Library](https://github.com/bluesky-social/indigo/tree/main/atproto/auth/oauth)
+10
View File
@@ -48,6 +48,14 @@ type ServerConfig struct {
// DebugAddr is the debug/pprof HTTP listen address (from env: ATCR_DEBUG_ADDR, default: ":5001")
DebugAddr string `yaml:"debug_addr"`
// 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")
// Auto-generated on first run for production (non-localhost) deployments
OAuthKeyPath string `yaml:"oauth_key_path"`
// ClientName is the OAuth client display name (from env: ATCR_CLIENT_NAME, default: "AT Container Registry")
// Shown in OAuth authorization screens
ClientName string `yaml:"client_name"`
}
// UIConfig defines web UI settings
@@ -123,6 +131,8 @@ func LoadConfigFromEnv() (*Config, error) {
return nil, fmt.Errorf("ATCR_DEFAULT_HOLD_DID is required")
}
cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true"
cfg.Server.OAuthKeyPath = getEnvOrDefault("ATCR_OAUTH_KEY_PATH", "/var/lib/atcr/oauth/client.key")
cfg.Server.ClientName = getEnvOrDefault("ATCR_CLIENT_NAME", "AT Container Registry")
// Auto-detect base URL if not explicitly set
cfg.Server.BaseURL = os.Getenv("ATCR_BASE_URL")
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"name": "At Container Registry",
"short_name": "ATCR.io",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
+60 -37
View File
@@ -7,6 +7,7 @@ package oauth
import (
"context"
"fmt"
"log/slog"
"net/url"
"strings"
@@ -23,13 +24,58 @@ type App struct {
}
// NewApp creates a new OAuth app for ATCR with default scopes
func NewApp(baseURL string, store oauth.ClientAuthStore, holdDid string, testMode bool) (*App, error) {
return NewAppWithScopes(baseURL, store, GetDefaultScopes(holdDid, testMode))
func NewApp(baseURL string, store oauth.ClientAuthStore, holdDid string, keyPath string, clientName string) (*App, error) {
return NewAppWithScopes(baseURL, store, GetDefaultScopes(holdDid), keyPath, clientName)
}
// NewAppWithScopes creates a new OAuth app for ATCR with custom scopes
func NewAppWithScopes(baseURL string, store oauth.ClientAuthStore, scopes []string) (*App, error) {
config := NewClientConfigWithScopes(baseURL, scopes)
// Automatically configures confidential client for production deployments
// keyPath specifies where to store/load the OAuth client P-256 key (ignored for localhost)
// clientName is added to OAuth client metadata
func NewAppWithScopes(baseURL string, store oauth.ClientAuthStore, scopes []string, keyPath string, clientName string) (*App, error) {
var config oauth.ClientConfig
redirectURI := RedirectURI(baseURL)
// If production (not localhost), automatically set up confidential client
if !isLocalhost(baseURL) {
clientID := baseURL + "/client-metadata.json"
config = oauth.NewPublicConfig(clientID, redirectURI, scopes)
// Generate or load P-256 key
privateKey, err := GenerateOrLoadClientKey(keyPath)
if err != nil {
return nil, fmt.Errorf("failed to load OAuth client key: %w", err)
}
// Generate key ID from public key
keyID, err := GenerateKeyID(privateKey)
if err != nil {
return nil, fmt.Errorf("failed to generate key ID: %w", err)
}
// Upgrade to confidential client
if err := config.SetClientSecret(privateKey, keyID); err != nil {
return nil, fmt.Errorf("failed to configure confidential client: %w", err)
}
slog.Info("Configured confidential OAuth client", "key_id", keyID, "key_path", keyPath)
} else {
config = oauth.NewLocalhostConfig(redirectURI, scopes)
// Append client_name to localhost client ID query string
if clientName != "" {
u, err := url.Parse(config.ClientID)
if err == nil {
q := u.Query()
q.Set("client_name", clientName)
u.RawQuery = q.Encode()
config.ClientID = u.String()
}
}
slog.Info("Using public OAuth client (localhost development)")
}
clientApp := oauth.NewClientApp(&config, store)
clientApp.Dir = atproto.GetDirectory()
@@ -39,23 +85,8 @@ func NewAppWithScopes(baseURL string, store oauth.ClientAuthStore, scopes []stri
}, nil
}
// NewClientConfigWithScopes creates an OAuth client configuration with custom scopes
func NewClientConfigWithScopes(baseURL string, scopes []string) oauth.ClientConfig {
clientID := ClientIDWithScopes(baseURL, scopes)
redirectURI := RedirectURI(baseURL)
// Check if this is localhost (public client) or production (confidential client)
if strings.Contains(baseURL, "127.0.0.1") || strings.Contains(baseURL, "localhost") {
return oauth.NewPublicConfig(clientID, redirectURI, scopes)
}
// Production: confidential client
// Note: Client secrets would be configured separately if needed
return oauth.NewPublicConfig(clientID, redirectURI, scopes)
}
func (a *App) GetConfig() oauth.ClientConfig {
return *a.clientApp.Config
func (a *App) GetConfig() *oauth.ClientConfig {
return a.clientApp.Config
}
// StartAuthFlow initiates an OAuth authorization flow for a given handle
@@ -104,19 +135,6 @@ func (a *App) Directory() identity.Directory {
return a.clientApp.Dir
}
// ClientIDWithScopes generates a client ID with custom scopes
func ClientIDWithScopes(baseURL string, scopes []string) string {
scopeStr := strings.Join(scopes, " ")
if strings.Contains(baseURL, "127.0.0.1") || strings.Contains(baseURL, "localhost") {
// Localhost: use query-based client ID
return fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s",
url.QueryEscape(RedirectURI(baseURL)),
url.QueryEscape(scopeStr))
}
// Production: use metadata URL
return baseURL + "/client-metadata.json"
}
// RedirectURI returns the OAuth redirect URI for ATCR
func RedirectURI(baseURL string) string {
return baseURL + "/auth/oauth/callback"
@@ -124,7 +142,7 @@ func RedirectURI(baseURL string) string {
// GetDefaultScopes returns the default OAuth scopes for ATCR registry operations
// testMode determines whether to use transition:generic (test) or rpc scopes (production)
func GetDefaultScopes(did string, testMode bool) []string {
func GetDefaultScopes(did string) []string {
scopes := []string{
"atproto",
// Image manifest types (single-arch)
@@ -135,10 +153,10 @@ func GetDefaultScopes(did string, testMode bool) []string {
"blob:application/vnd.docker.distribution.manifest.list.v2+json",
// OCI artifact manifests (for cosign signatures, SBOMs, attestations)
"blob:application/vnd.cncf.oras.artifact.manifest.v1+json",
// Used for service token validation on holds
"rpc:com.atproto.repo.getRecord?aud=*",
}
scopes = append(scopes, fmt.Sprintf("rpc:com.atproto.repo.getRecord?aud=%s", "*"))
// Add repo scopes
scopes = append(scopes,
fmt.Sprintf("repo:%s", atproto.ManifestCollection),
@@ -176,3 +194,8 @@ func ScopesMatch(stored, desired []string) bool {
return true
}
// isLocalhost checks if a base URL is a localhost address
func isLocalhost(baseURL string) bool {
return strings.Contains(baseURL, "127.0.0.1") || strings.Contains(baseURL, "localhost")
}
+4 -2
View File
@@ -7,6 +7,7 @@ import (
func TestNewApp(t *testing.T) {
tmpDir := t.TempDir()
storePath := tmpDir + "/oauth-test.json"
keyPath := tmpDir + "/oauth-key.bin"
store, err := NewFileStore(storePath)
if err != nil {
@@ -16,7 +17,7 @@ func TestNewApp(t *testing.T) {
baseURL := "http://localhost:5000"
holdDID := "did:web:hold.example.com"
app, err := NewApp(baseURL, store, holdDID, false)
app, err := NewApp(baseURL, store, holdDID, keyPath, "AT Container Registry")
if err != nil {
t.Fatalf("NewApp() error = %v", err)
}
@@ -33,6 +34,7 @@ func TestNewApp(t *testing.T) {
func TestNewAppWithScopes(t *testing.T) {
tmpDir := t.TempDir()
storePath := tmpDir + "/oauth-test.json"
keyPath := tmpDir + "/oauth-key.bin"
store, err := NewFileStore(storePath)
if err != nil {
@@ -42,7 +44,7 @@ func TestNewAppWithScopes(t *testing.T) {
baseURL := "http://localhost:5000"
scopes := []string{"atproto", "custom:scope"}
app, err := NewAppWithScopes(baseURL, store, scopes)
app, err := NewAppWithScopes(baseURL, store, scopes, keyPath, "AT Container Registry")
if err != nil {
t.Fatalf("NewAppWithScopes() error = %v", err)
}
+4 -2
View File
@@ -35,11 +35,13 @@ func InteractiveFlowWithCallback(
// Create OAuth app with custom scopes (or defaults if nil)
// Interactive flows are typically for production use (credential helper, etc.)
// so we default to testMode=false
// For CLI tools, we use an empty keyPath since they're typically localhost (public client)
// or ephemeral sessions
var app *App
if scopes != nil {
app, err = NewAppWithScopes(baseURL, store, scopes)
app, err = NewAppWithScopes(baseURL, store, scopes, "", "AT Container Registry")
} else {
app, err = NewApp(baseURL, store, "*", false)
app, err = NewApp(baseURL, store, "*", "", "AT Container Registry")
}
if err != nil {
return nil, fmt.Errorf("failed to create OAuth app: %w", err)
+193
View File
@@ -0,0 +1,193 @@
package oauth
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/bluesky-social/indigo/atproto/atcrypto"
)
// KeyType represents the elliptic curve algorithm for key generation
type KeyType int
const (
// KeyTypeP256 uses NIST P-256 (ES256) - standard for OAuth/OIDC
KeyTypeP256 KeyType = iota
// KeyTypeK256 uses secp256k1 (ES256K) - used for ATProto PDS signing
KeyTypeK256
)
// GenerateOrLoadKey generates a new key pair or loads an existing one
// Supports both P-256 (OAuth) and K-256 (ATProto PDS) key types
func GenerateOrLoadKey(keyPath string, keyType KeyType) (atcrypto.PrivateKey, error) {
// Ensure directory exists
dir := filepath.Dir(keyPath)
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("failed to create key directory: %w", err)
}
// Check if key already exists
if _, err := os.Stat(keyPath); err == nil {
// Key exists, load it
return loadKey(keyPath, keyType)
}
// Key doesn't exist, generate new one
return generateKey(keyPath, keyType)
}
// generateKey creates a new key pair of the specified type
func generateKey(keyPath string, keyType KeyType) (atcrypto.PrivateKey, error) {
var privateKey atcrypto.PrivateKey
var keyName string
var err error
switch keyType {
case KeyTypeP256:
privateKey, err = atcrypto.GeneratePrivateKeyP256()
keyName = "P-256"
case KeyTypeK256:
privateKey, err = atcrypto.GeneratePrivateKeyK256()
keyName = "K-256"
default:
return nil, fmt.Errorf("unsupported key type: %d", keyType)
}
if err != nil {
return nil, fmt.Errorf("failed to generate %s key: %w", keyName, err)
}
// Serialize key to bytes
exportableKey, ok := privateKey.(atcrypto.PrivateKeyExportable)
if !ok {
return nil, fmt.Errorf("key does not support export")
}
keyBytes := exportableKey.Bytes()
// Write to file with restrictive permissions
if err := os.WriteFile(keyPath, keyBytes, 0600); err != nil {
return nil, fmt.Errorf("failed to write key file: %w", err)
}
slog.Info("Generated new signing key", "type", keyName, "path", keyPath)
return privateKey, nil
}
// loadKey loads an existing private key from disk
func loadKey(keyPath string, keyType KeyType) (atcrypto.PrivateKey, error) {
// Read key bytes
keyBytes, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("failed to read key file: %w", err)
}
var privateKey atcrypto.PrivateKey
var keyName string
switch keyType {
case KeyTypeP256:
privateKey, err = atcrypto.ParsePrivateBytesP256(keyBytes)
keyName = "P-256"
case KeyTypeK256:
// Check for old PEM format (migration path)
if IsPEMFormat(keyBytes) {
slog.Warn("Detected old P-256 PEM key, replacing with K-256")
return generateKey(keyPath, keyType)
}
privateKey, err = atcrypto.ParsePrivateBytesK256(keyBytes)
keyName = "K-256"
default:
return nil, fmt.Errorf("unsupported key type: %d", keyType)
}
if err != nil {
return nil, fmt.Errorf("failed to parse %s private key: %w", keyName, err)
}
slog.Info("Loaded existing signing key", "type", keyName, "path", keyPath)
return privateKey, nil
}
// IsPEMFormat checks if bytes are in PEM format (for migration detection)
// Exported for testing and migration utilities
func IsPEMFormat(data []byte) bool {
return len(data) > 10 && string(data[:5]) == "-----"
}
// GenerateOrLoadClientKey generates a new P256 key pair or loads an existing one
// This is a convenience wrapper for OAuth client keys
func GenerateOrLoadClientKey(keyPath string) (*atcrypto.PrivateKeyP256, error) {
key, err := GenerateOrLoadKey(keyPath, KeyTypeP256)
if err != nil {
return nil, err
}
p256Key, ok := key.(*atcrypto.PrivateKeyP256)
if !ok {
return nil, fmt.Errorf("expected P-256 key, got different type")
}
return p256Key, nil
}
// GenerateOrLoadPDSKey generates a new K256 key pair or loads an existing one
// This is a convenience wrapper for ATProto PDS signing keys
func GenerateOrLoadPDSKey(keyPath string) (*atcrypto.PrivateKeyK256, error) {
key, err := GenerateOrLoadKey(keyPath, KeyTypeK256)
if err != nil {
return nil, err
}
k256Key, ok := key.(*atcrypto.PrivateKeyK256)
if !ok {
return nil, fmt.Errorf("expected K-256 key, got different type")
}
return k256Key, nil
}
// GenerateKeyID generates a stable key ID from a P256 public key
// Uses the first 8 characters of the hex-encoded SHA256 hash of the public key bytes
func GenerateKeyID(privateKey *atcrypto.PrivateKeyP256) (string, error) {
// Get public key
pubKey, err := privateKey.PublicKey()
if err != nil {
return "", fmt.Errorf("failed to get public key: %w", err)
}
// Get public key bytes
pubKeyBytes := pubKey.Bytes()
// Hash public key bytes
hash := sha256.Sum256(pubKeyBytes)
// Return first 8 characters of hex-encoded hash
return hex.EncodeToString(hash[:])[:8], nil
}
// PrivateKeyToMultibase converts a P256 private key to multibase format
// Required by indigo's SetClientSecret() API
func PrivateKeyToMultibase(key *atcrypto.PrivateKeyP256) string {
return key.Multibase()
}
// MultibaseToPrivateKey parses a multibase-encoded P256 private key
func MultibaseToPrivateKey(encoded string) (*atcrypto.PrivateKeyP256, error) {
// ParsePrivateMultibase returns PrivateKeyExportable interface
key, err := atcrypto.ParsePrivateMultibase(encoded)
if err != nil {
return nil, fmt.Errorf("failed to parse multibase key: %w", err)
}
// Type assert to P256 key
p256Key, ok := key.(*atcrypto.PrivateKeyP256)
if !ok {
return nil, fmt.Errorf("expected P-256 key, got different key type")
}
return p256Key, nil
}
+2 -2
View File
@@ -13,7 +13,7 @@ func TestNewRefresher(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err)
}
app, err := NewApp("http://localhost:5000", store, "*", false)
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
if err != nil {
t.Fatalf("NewApp() error = %v", err)
}
@@ -45,7 +45,7 @@ func TestRefresher_SetUISessionStore(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err)
}
app, err := NewApp("http://localhost:5000", store, "*", false)
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
if err != nil {
t.Fatalf("NewApp() error = %v", err)
}
+12 -12
View File
@@ -19,7 +19,7 @@ func TestNewServer(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err)
}
app, err := NewApp("http://localhost:5000", store, "*", false)
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
if err != nil {
t.Fatalf("NewApp() error = %v", err)
}
@@ -43,7 +43,7 @@ func TestServer_SetRefresher(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err)
}
app, err := NewApp("http://localhost:5000", store, "*", false)
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
if err != nil {
t.Fatalf("NewApp() error = %v", err)
}
@@ -66,7 +66,7 @@ func TestServer_SetPostAuthCallback(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err)
}
app, err := NewApp("http://localhost:5000", store, "*", false)
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
if err != nil {
t.Fatalf("NewApp() error = %v", err)
}
@@ -92,7 +92,7 @@ func TestServer_SetUISessionStore(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err)
}
app, err := NewApp("http://localhost:5000", store, "*", false)
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
if err != nil {
t.Fatalf("NewApp() error = %v", err)
}
@@ -155,7 +155,7 @@ func TestServer_ServeAuthorize_MissingHandle(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err)
}
app, err := NewApp("http://localhost:5000", store, "*", false)
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
if err != nil {
t.Fatalf("NewApp() error = %v", err)
}
@@ -182,7 +182,7 @@ func TestServer_ServeAuthorize_InvalidMethod(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err)
}
app, err := NewApp("http://localhost:5000", store, "*", false)
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
if err != nil {
t.Fatalf("NewApp() error = %v", err)
}
@@ -211,7 +211,7 @@ func TestServer_ServeCallback_InvalidMethod(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err)
}
app, err := NewApp("http://localhost:5000", store, "*", false)
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
if err != nil {
t.Fatalf("NewApp() error = %v", err)
}
@@ -238,7 +238,7 @@ func TestServer_ServeCallback_OAuthError(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err)
}
app, err := NewApp("http://localhost:5000", store, "*", false)
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
if err != nil {
t.Fatalf("NewApp() error = %v", err)
}
@@ -270,7 +270,7 @@ func TestServer_ServeCallback_WithPostAuthCallback(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err)
}
app, err := NewApp("http://localhost:5000", store, "*", false)
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
if err != nil {
t.Fatalf("NewApp() error = %v", err)
}
@@ -314,7 +314,7 @@ func TestServer_ServeCallback_UIFlow_SessionCreationLogic(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err)
}
app, err := NewApp("http://localhost:5000", store, "*", false)
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
if err != nil {
t.Fatalf("NewApp() error = %v", err)
}
@@ -343,7 +343,7 @@ func TestServer_RenderError(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err)
}
app, err := NewApp("http://localhost:5000", store, "*", false)
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
if err != nil {
t.Fatalf("NewApp() error = %v", err)
}
@@ -377,7 +377,7 @@ func TestServer_RenderRedirectToSettings(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err)
}
app, err := NewApp("http://localhost:5000", store, "*", false)
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
if err != nil {
t.Fatalf("NewApp() error = %v", err)
}
+2 -1
View File
@@ -14,6 +14,7 @@ import (
"testing"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
"atcr.io/pkg/hold/pds"
"atcr.io/pkg/s3"
"github.com/distribution/distribution/v3/registry/storage/driver/factory"
@@ -37,7 +38,7 @@ func TestMain(m *testing.M) {
// Generate one signing key to be reused across all tests
sharedTestKeyPath = filepath.Join(tmpDir, "shared-signing-key")
privateKey, err := pds.GenerateOrLoadKey(sharedTestKeyPath)
privateKey, err := oauth.GenerateOrLoadPDSKey(sharedTestKeyPath)
if err != nil {
panic(fmt.Sprintf("Failed to generate shared signing key: %v", err))
}
-78
View File
@@ -1,78 +0,0 @@
package pds
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/bluesky-social/indigo/atproto/atcrypto"
)
// GenerateOrLoadKey generates a new K256 key pair or loads an existing one
func GenerateOrLoadKey(keyPath string) (*atcrypto.PrivateKeyK256, error) {
// Ensure directory exists
dir := filepath.Dir(keyPath)
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("failed to create key directory: %w", err)
}
// Check if key already exists
if _, err := os.Stat(keyPath); err == nil {
// Key exists, load it
return loadKey(keyPath)
}
// Key doesn't exist, generate new one
return generateKey(keyPath)
}
// generateKey creates a new K256 (secp256k1) key pair using indigo's atcrypto
func generateKey(keyPath string) (*atcrypto.PrivateKeyK256, error) {
// Generate K256 key (secp256k1) using indigo
privateKey, err := atcrypto.GeneratePrivateKeyK256()
if err != nil {
return nil, fmt.Errorf("failed to generate key: %w", err)
}
// Serialize key to bytes
keyBytes := privateKey.Bytes()
// Write to file with restrictive permissions
if err := os.WriteFile(keyPath, keyBytes, 0600); err != nil {
return nil, fmt.Errorf("failed to write key file: %w", err)
}
slog.Info("Generated new K-256 signing key", "path", keyPath)
return privateKey, nil
}
// loadKey loads an existing private key from disk
func loadKey(keyPath string) (*atcrypto.PrivateKeyK256, error) {
// Read key bytes
keyBytes, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("failed to read key file: %w", err)
}
// Try to parse as K256 private key
privateKey, err := atcrypto.ParsePrivateBytesK256(keyBytes)
if err != nil {
// Check if this is an old P-256 PEM key (migration)
if isPEMFormat(keyBytes) {
slog.Warn("Detected old P-256 key, replacing with K-256")
// Generate new K-256 key (overwrites old P-256)
return generateKey(keyPath)
}
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
slog.Info("Loaded existing K-256 signing key", "path", keyPath)
return privateKey, nil
}
// isPEMFormat checks if bytes are in PEM format (old P-256 keys)
func isPEMFormat(data []byte) bool {
return len(data) > 10 && string(data[:5]) == "-----"
}
-437
View File
@@ -1,437 +0,0 @@
package pds
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/bluesky-social/indigo/atproto/atcrypto"
)
// TestGenerateOrLoadKey_Generate tests generating a new key
func TestGenerateOrLoadKey_Generate(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "test-key")
// Verify key doesn't exist yet
if _, err := os.Stat(keyPath); !os.IsNotExist(err) {
t.Fatal("Expected key file to not exist")
}
// Generate key
key, err := GenerateOrLoadKey(keyPath)
if err != nil {
t.Fatalf("GenerateOrLoadKey failed: %v", err)
}
if key == nil {
t.Fatal("Expected non-nil key")
}
// Verify key file was created
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
t.Error("Expected key file to be created")
}
// Verify key file has restrictive permissions (0600)
fileInfo, err := os.Stat(keyPath)
if err != nil {
t.Fatalf("Failed to stat key file: %v", err)
}
perm := fileInfo.Mode().Perm()
expectedPerm := os.FileMode(0600)
if perm != expectedPerm {
t.Errorf("Expected key file permissions %o, got %o", expectedPerm, perm)
}
// Verify key can sign data
testData := []byte("test data")
signature, err := key.HashAndSign(testData)
if err != nil {
t.Fatalf("Failed to sign with generated key: %v", err)
}
if len(signature) == 0 {
t.Error("Expected non-empty signature")
}
// Verify signature
pubKey, err := key.PublicKey()
if err != nil {
t.Fatalf("Failed to get public key: %v", err)
}
err = pubKey.HashAndVerify(testData, signature)
if err != nil {
t.Fatalf("Failed to verify signature: %v", err)
}
}
// TestGenerateOrLoadKey_Load tests loading an existing key
func TestGenerateOrLoadKey_Load(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "test-key")
// Generate initial key
key1, err := GenerateOrLoadKey(keyPath)
if err != nil {
t.Fatalf("GenerateOrLoadKey failed on first call: %v", err)
}
// Get key bytes for comparison
key1Bytes := key1.Bytes()
// Load the same key again
key2, err := GenerateOrLoadKey(keyPath)
if err != nil {
t.Fatalf("GenerateOrLoadKey failed on second call: %v", err)
}
// Get key2 bytes
key2Bytes := key2.Bytes()
// Verify keys are identical
if len(key1Bytes) != len(key2Bytes) {
t.Fatalf("Key byte length mismatch: %d vs %d", len(key1Bytes), len(key2Bytes))
}
for i := range key1Bytes {
if key1Bytes[i] != key2Bytes[i] {
t.Errorf("Key byte mismatch at position %d: %x vs %x", i, key1Bytes[i], key2Bytes[i])
}
}
// Verify both keys produce same signature for same data
testData := []byte("consistent test data")
sig1, err := key1.HashAndSign(testData)
if err != nil {
t.Fatalf("Failed to sign with key1: %v", err)
}
// Verify sig1 with key2's public key
pubKey2, err := key2.PublicKey()
if err != nil {
t.Fatalf("Failed to get public key from key2: %v", err)
}
err = pubKey2.HashAndVerify(testData, sig1)
if err != nil {
t.Error("Signature from key1 should verify with key2 (they're the same key)")
}
}
// TestGenerateOrLoadKey_P256Migration tests migrating from old P-256 keys
func TestGenerateOrLoadKey_P256Migration(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "old-pem-key")
// Create a fake PEM file (old P-256 format)
pemContent := []byte(`-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIFakeKeyDataHereThisIsNotARealKeyButHasPEMFormat
-----END EC PRIVATE KEY-----`)
err := os.WriteFile(keyPath, pemContent, 0600)
if err != nil {
t.Fatalf("Failed to write fake PEM key: %v", err)
}
// Verify file exists and is in PEM format
data, err := os.ReadFile(keyPath)
if err != nil {
t.Fatalf("Failed to read key file: %v", err)
}
if !isPEMFormat(data) {
t.Fatal("Expected key file to be in PEM format")
}
// Call GenerateOrLoadKey - should detect PEM and generate new K-256 key
key, err := GenerateOrLoadKey(keyPath)
if err != nil {
t.Fatalf("GenerateOrLoadKey failed during P-256 migration: %v", err)
}
if key == nil {
t.Fatal("Expected non-nil key after migration")
}
// Verify key file was replaced (no longer PEM)
newData, err := os.ReadFile(keyPath)
if err != nil {
t.Fatalf("Failed to read new key file: %v", err)
}
if isPEMFormat(newData) {
t.Error("Expected key file to no longer be in PEM format after migration")
}
// Verify new key is K-256 and works
testData := []byte("test after migration")
signature, err := key.HashAndSign(testData)
if err != nil {
t.Fatalf("Failed to sign with migrated key: %v", err)
}
pubKey, err := key.PublicKey()
if err != nil {
t.Fatalf("Failed to get public key: %v", err)
}
err = pubKey.HashAndVerify(testData, signature)
if err != nil {
t.Fatalf("Failed to verify signature from migrated key: %v", err)
}
}
// TestKeyPersistence tests that key bytes survive save/load cycle
func TestKeyPersistence(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "persist-key")
// Generate key
originalKey, err := GenerateOrLoadKey(keyPath)
if err != nil {
t.Fatalf("GenerateOrLoadKey failed: %v", err)
}
// Get original key bytes
originalBytes := originalKey.Bytes()
// Read key file directly
fileBytes, err := os.ReadFile(keyPath)
if err != nil {
t.Fatalf("Failed to read key file: %v", err)
}
// Verify file bytes match key bytes
if len(fileBytes) != len(originalBytes) {
t.Fatalf("File byte length mismatch: %d vs %d", len(fileBytes), len(originalBytes))
}
for i := range originalBytes {
if fileBytes[i] != originalBytes[i] {
t.Errorf("File byte mismatch at position %d: %x vs %x", i, fileBytes[i], originalBytes[i])
}
}
// Parse key directly from file bytes
parsedKey, err := atcrypto.ParsePrivateBytesK256(fileBytes)
if err != nil {
t.Fatalf("Failed to parse key from file bytes: %v", err)
}
// Verify parsed key matches original
parsedBytes := parsedKey.Bytes()
if len(parsedBytes) != len(originalBytes) {
t.Fatalf("Parsed key byte length mismatch: %d vs %d", len(parsedBytes), len(originalBytes))
}
for i := range originalBytes {
if parsedBytes[i] != originalBytes[i] {
t.Errorf("Parsed key byte mismatch at position %d: %x vs %x", i, parsedBytes[i], originalBytes[i])
}
}
}
// TestGenerateOrLoadKey_DirectoryCreation tests that parent directory is created
func TestGenerateOrLoadKey_DirectoryCreation(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "nested", "dir", "test-key")
// Verify nested directories don't exist
nestedDir := filepath.Join(tmpDir, "nested", "dir")
if _, err := os.Stat(nestedDir); !os.IsNotExist(err) {
t.Fatal("Expected nested directory to not exist")
}
// Generate key (should create directories)
key, err := GenerateOrLoadKey(keyPath)
if err != nil {
t.Fatalf("GenerateOrLoadKey failed: %v", err)
}
if key == nil {
t.Fatal("Expected non-nil key")
}
// Verify directories were created
if _, err := os.Stat(nestedDir); os.IsNotExist(err) {
t.Error("Expected nested directory to be created")
}
// Verify key file exists
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
t.Error("Expected key file to be created")
}
// Verify directory has restrictive permissions (0700)
dirInfo, err := os.Stat(nestedDir)
if err != nil {
t.Fatalf("Failed to stat directory: %v", err)
}
dirPerm := dirInfo.Mode().Perm()
expectedDirPerm := os.FileMode(0700)
if dirPerm != expectedDirPerm {
t.Errorf("Expected directory permissions %o, got %o", expectedDirPerm, dirPerm)
}
}
// TestIsPEMFormat tests the PEM format detection
func TestIsPEMFormat(t *testing.T) {
tests := []struct {
name string
data []byte
expected bool
}{
{
name: "Valid PEM",
data: []byte("-----BEGIN EC PRIVATE KEY-----\ndata\n-----END EC PRIVATE KEY-----"),
expected: true,
},
{
name: "Valid PEM (RSA)",
data: []byte("-----BEGIN RSA PRIVATE KEY-----\ndata\n-----END RSA PRIVATE KEY-----"),
expected: true,
},
{
name: "Binary data",
data: []byte{0x01, 0x02, 0x03, 0x04, 0x05},
expected: false,
},
{
name: "Empty data",
data: []byte{},
expected: false,
},
{
name: "Short data",
data: []byte("----"),
expected: false,
},
{
name: "Almost PEM (missing dashes)",
data: []byte("----BEGIN KEY-----"),
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isPEMFormat(tt.data)
if result != tt.expected {
t.Errorf("Expected isPEMFormat=%v, got %v", tt.expected, result)
}
})
}
}
// TestGenerateKey_UniqueKeys tests that each generated key is unique
func TestGenerateKey_UniqueKeys(t *testing.T) {
tmpDir := t.TempDir()
// Generate multiple keys
var keyBytes [][]byte
for i := 0; i < 5; i++ {
keyPath := filepath.Join(tmpDir, "key-"+string(rune('a'+i)))
key, err := GenerateOrLoadKey(keyPath)
if err != nil {
t.Fatalf("GenerateOrLoadKey failed for key %d: %v", i, err)
}
keyBytes = append(keyBytes, key.Bytes())
}
// Verify all keys are different
for i := 0; i < len(keyBytes); i++ {
for j := i + 1; j < len(keyBytes); j++ {
// Keys should be different
identical := true
if len(keyBytes[i]) != len(keyBytes[j]) {
identical = false
} else {
for k := range keyBytes[i] {
if keyBytes[i][k] != keyBytes[j][k] {
identical = false
break
}
}
}
if identical {
t.Errorf("Keys %d and %d are identical (expected unique keys)", i, j)
}
}
}
}
// TestLoadKey_InvalidFormat tests loading key with invalid format
func TestLoadKey_InvalidFormat(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "invalid-key")
// Write invalid data (not a valid K-256 key and not PEM)
invalidData := []byte("This is not a valid key format at all")
err := os.WriteFile(keyPath, invalidData, 0600)
if err != nil {
t.Fatalf("Failed to write invalid key: %v", err)
}
// Try to load (should fail with parse error, then try to generate new key)
// Since it's not PEM, it will try to parse as K-256 and fail,
// then NOT migrate (migration only happens for PEM), so it should error
_, err = GenerateOrLoadKey(keyPath)
if err == nil {
t.Fatal("Expected error when loading invalid key format")
}
// Error should mention parsing failure
if err != nil && err.Error() == "" {
t.Error("Expected non-empty error message")
}
}
// TestGenerateOrLoadKey_CorruptedKey tests behavior with corrupted key file
func TestGenerateOrLoadKey_CorruptedKey(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "corrupted-key")
// Generate valid key first
key1, err := GenerateOrLoadKey(keyPath)
if err != nil {
t.Fatalf("GenerateOrLoadKey failed: %v", err)
}
originalBytes := key1.Bytes()
// Corrupt the key file (flip some bits in the middle)
corruptedBytes := make([]byte, len(originalBytes))
copy(corruptedBytes, originalBytes)
if len(corruptedBytes) > 10 {
corruptedBytes[5] ^= 0xFF
corruptedBytes[10] ^= 0xFF
}
err = os.WriteFile(keyPath, corruptedBytes, 0600)
if err != nil {
t.Fatalf("Failed to write corrupted key: %v", err)
}
// Try to load corrupted key
// Note: K256 keys are flexible, so slightly corrupted keys might still be valid
// We just verify that it either loads successfully or returns an error
key2, err := GenerateOrLoadKey(keyPath)
if err != nil {
// Expected - corrupted key failed to load
t.Logf("Corrupted key failed to load as expected: %v", err)
return
}
// If it loaded, verify it's different from the original
key2Bytes := key2.Bytes()
if bytes.Equal(originalBytes, key2Bytes) {
t.Error("Corrupted key loaded with same bytes as original (unexpected)")
}
}
+2 -1
View File
@@ -9,6 +9,7 @@ import (
"strings"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/bluesky-social/indigo/carstore"
lexutil "github.com/bluesky-social/indigo/lex/util"
@@ -44,7 +45,7 @@ type HoldPDS struct {
// NewHoldPDS creates or opens a hold PDS with SQLite carstore
func NewHoldPDS(ctx context.Context, did, publicURL, dbPath, keyPath string, enableBlueskyPosts bool) (*HoldPDS, error) {
// Generate or load signing key
signingKey, err := GenerateOrLoadKey(keyPath)
signingKey, err := oauth.GenerateOrLoadPDSKey(keyPath)
if err != nil {
return nil, fmt.Errorf("failed to initialize signing key: %w", err)
}
+2 -1
View File
@@ -12,6 +12,7 @@ import (
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
"atcr.io/pkg/s3"
bsky "github.com/bluesky-social/indigo/api/bsky"
)
@@ -265,7 +266,7 @@ func TestMain(m *testing.M) {
// Generate one signing key to be reused across all tests in the package
sharedTestKeyPath = filepath.Join(tmpDir, "shared-signing-key")
privateKey, err := GenerateOrLoadKey(sharedTestKeyPath)
privateKey, err := oauth.GenerateOrLoadPDSKey(sharedTestKeyPath)
if err != nil {
panic(fmt.Sprintf("Failed to generate shared signing key: %v", err))
}