diff --git a/.env.example b/.env.example index c9d1135..f14a78f 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,19 @@ GITEA_TOKEN=your-bot-user-token-here # MCP Server Configuration MCP_HOST=0.0.0.0 MCP_PORT=8080 +MCP_DOMAIN=mcp.yourdomain.com # Domain for Traefik (if using) + +# Authentication Configuration (REQUIRED) +# Generate key with: python scripts/generate_api_key.py +AUTH_ENABLED=true +MCP_API_KEYS=your-generated-api-key-here + +# Multiple keys (comma-separated for grace period during rotation): +# MCP_API_KEYS=key1,key2,key3 + +# Authentication limits +MAX_AUTH_FAILURES=5 # Max failures before rate limiting +AUTH_FAILURE_WINDOW=300 # Time window in seconds (5 min) # Logging Configuration LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore index 5f99a18..cd764ad 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,11 @@ htmlcov/ # Environment variables .env .env.local +.env.backup-* + +# API keys +keys/ +*.key # Logs *.log diff --git a/AUTH_SETUP.md b/AUTH_SETUP.md new file mode 100644 index 0000000..30ab634 --- /dev/null +++ b/AUTH_SETUP.md @@ -0,0 +1,434 @@ +# Authentication Setup Guide + +This guide walks you through setting up API key authentication for AegisGitea MCP to ensure only your ChatGPT workspace can access your self-hosted Gitea instance. + +--- + +## Overview + +AegisGitea MCP uses **Bearer Token authentication** to secure access: + +- **API Keys**: Cryptographically secure 64-character tokens +- **Header-based**: Keys sent via `Authorization: Bearer ` header +- **Multi-key support**: Multiple keys for rotation grace periods +- **Rate limiting**: Failed auth attempts trigger IP-based rate limits +- **Audit logging**: All auth attempts logged for security monitoring + +--- + +## Quick Start (5 minutes) + +### 1. Generate API Key + +```bash +# Using Make +make generate-key + +# Or directly +python3 scripts/generate_api_key.py +``` + +This will: +- Generate a secure 64-character API key +- Show you the key (save it immediately!) +- Provide `.env` configuration snippet +- Optionally save metadata (not the key itself) for tracking + +**Example output:** +``` +āœ“ API Key Generated Successfully! + +API KEY: + a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2 + +šŸ“‹ Next Steps: +1. Add this key to your .env file +2. Restart the MCP server +3. Configure ChatGPT Business +``` + +### 2. Add Key to .env + +```bash +# Edit .env +nano .env + +# Add/update this line: +MCP_API_KEYS=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2 +``` + +### 3. Restart MCP Server + +```bash +docker-compose restart aegis-mcp + +# Verify authentication is enabled +docker-compose logs aegis-mcp | grep "authentication" +``` + +Expected log output: +``` +API key authentication ENABLED (1 key(s) configured) +``` + +### 4. Test Authentication + +```bash +# Without key - should fail with 401 +curl https://mcp.yourdomain.com/mcp/tools + +# With valid key - should succeed +curl -H "Authorization: Bearer YOUR_KEY_HERE" \ + https://mcp.yourdomain.com/mcp/tools +``` + +--- + +## Configuration Options + +### Environment Variables + +```bash +# Enable/disable authentication +AUTH_ENABLED=true # Set to false ONLY for testing + +# API keys (comma-separated for multiple) +MCP_API_KEYS=key1 # Single key +MCP_API_KEYS=key1,key2,key3 # Multiple keys (rotation grace period) + +# Rate limiting +MAX_AUTH_FAILURES=5 # Max failed attempts before blocking +AUTH_FAILURE_WINDOW=300 # Time window in seconds (5 min) +``` + +### Multiple Keys (Rotation Grace Period) + +During key rotation, you can temporarily allow multiple keys: + +```bash +# Old key +MCP_API_KEYS=old-key-here + +# Add new key (both work) +MCP_API_KEYS=old-key-here,new-key-here + +# After updating ChatGPT, remove old key +MCP_API_KEYS=new-key-here +``` + +--- + +## Security Features + +### 1. Constant-Time Comparison + +Keys are compared using `hmac.compare_digest()` to prevent timing attacks. + +### 2. Rate Limiting + +- **Threshold**: 5 failed attempts per IP +- **Window**: 5 minutes (configurable) +- **Action**: Reject all requests from that IP until window expires +- **Logging**: High-severity security event logged + +### 3. Audit Logging + +Every authentication attempt logs: + +```json +{ + "timestamp": "2026-01-29T12:34:56.789Z", + "event": "api_authentication", + "status": "success", + "client_ip": "203.0.113.42", + "user_agent": "ChatGPT-User/1.0", + "key_hint": "a1b2c3d4...e1f2" +} +``` + +Failed attempts: + +```json +{ + "timestamp": "2026-01-29T12:34:56.789Z", + "event": "access_denied", + "reason": "invalid_api_key", + "client_ip": "203.0.113.42" +} +``` + +### 4. Key Format Validation + +- Minimum length: 32 characters +- Recommended length: 64 characters +- Format: Hexadecimal string (0-9, a-f) + +--- + +## Troubleshooting + +### Issue: "No API keys configured" error + +**Symptoms:** +``` +docker-compose logs: "No API keys configured in environment" +``` + +**Solution:** +1. Check `.env` file exists and contains `MCP_API_KEYS` +2. Ensure no typos (`MCP_API_KEYS` not `MCP_API_KEY`) +3. Verify key is at least 32 characters +4. Restart container after updating `.env` + +### Issue: "Invalid API key" on valid key + +**Possible causes:** + +1. **Whitespace in .env**: Remove spaces around `=` + ```bash + # Wrong + MCP_API_KEYS = key-here + + # Correct + MCP_API_KEYS=key-here + ``` + +2. **Key truncated**: Ensure entire 64-char key is copied + ```bash + # Check key length + echo -n "your-key-here" | wc -c + # Should output: 64 + ``` + +3. **Container not restarted**: Always restart after changing `.env` + ```bash + docker-compose restart aegis-mcp + ``` + +### Issue: Rate limit blocking legitimate requests + +**Symptoms:** +``` +"Too many failed authentication attempts" +``` + +**Solution:** +1. Check audit logs for failed attempts: + ```bash + docker-compose exec aegis-mcp cat /var/log/aegis-mcp/audit.log | grep "invalid_api_key" + ``` + +2. Wait 5 minutes for rate limit to reset + +3. If accidentally blocked yourself: + ```bash + # Restart container (clears in-memory rate limits) + docker-compose restart aegis-mcp + ``` + +4. Adjust rate limit settings in `.env` if needed: + ```bash + MAX_AUTH_FAILURES=10 + AUTH_FAILURE_WINDOW=600 + ``` + +### Issue: ChatGPT can't connect after adding auth + +See [CHATGPT_SETUP.md](CHATGPT_SETUP.md) for detailed ChatGPT configuration. + +**Quick check:** +1. Verify key in ChatGPT settings matches `.env` +2. Check Authorization header format: `Bearer ` (with space) +3. Test manually with curl first + +--- + +## Monitoring Authentication + +### View All Auth Attempts + +```bash +# All auth events +docker-compose exec aegis-mcp grep "api_authentication" /var/log/aegis-mcp/audit.log + +# Failed attempts only +docker-compose exec aegis-mcp grep "access_denied" /var/log/aegis-mcp/audit.log + +# Rate limit triggers +docker-compose exec aegis-mcp grep "auth_rate_limit_exceeded" /var/log/aegis-mcp/audit.log +``` + +### Real-Time Monitoring + +```bash +# Follow auth events +docker-compose exec aegis-mcp tail -f /var/log/aegis-mcp/audit.log | grep "auth" +``` + +### Weekly Summary Script + +```bash +#!/bin/bash +# Save as scripts/auth_summary.sh + +CONTAINER="aegis-gitea-mcp" +LOG_PATH="/var/log/aegis-mcp/audit.log" + +echo "=== Weekly Auth Summary ===" +echo "" +echo "Total auth attempts:" +docker exec $CONTAINER grep "api_authentication" $LOG_PATH | wc -l + +echo "" +echo "Successful:" +docker exec $CONTAINER grep "api_authentication.*success" $LOG_PATH | wc -l + +echo "" +echo "Failed:" +docker exec $CONTAINER grep "access_denied.*invalid_api_key" $LOG_PATH | wc -l + +echo "" +echo "Rate limited IPs:" +docker exec $CONTAINER grep "auth_rate_limit_exceeded" $LOG_PATH | wc -l +``` + +--- + +## Best Practices + +### 1. Key Storage + +- āœ… Store in `.env` file (never commit to git) +- āœ… Use password manager for backup +- āœ… Save key metadata (not key itself) for tracking +- āŒ Never hardcode in application code +- āŒ Never share via unencrypted channels + +### 2. Key Rotation + +- **Schedule**: Every 90 days (automated check available) +- **Method**: Use `make rotate-key` for guided rotation +- **Grace period**: Temporarily allow both old and new keys +- **Verification**: Test new key before removing old key + +See [KEY_ROTATION.md](KEY_ROTATION.md) for detailed rotation procedures. + +### 3. Access Control + +- **Single workspace**: One key for your ChatGPT Business account +- **Multiple users**: One key per user (track with metadata) +- **Revocation**: Remove key from `.env` and restart + +### 4. Monitoring + +- **Weekly**: Review auth logs for anomalies +- **Monthly**: Check key age with `make check-key-age` +- **Quarterly**: Rotate keys +- **Alerts**: Set up notifications for rate limit events + +--- + +## Advanced Configuration + +### Disable Authentication (Testing Only) + +**āš ļø WARNING: Only for isolated test environments** + +```bash +# .env +AUTH_ENABLED=false +``` + +Server will log critical warning: +``` +API key authentication DISABLED - server is open to all requests! +``` + +### Custom Rate Limiting + +```bash +# More lenient (for high-traffic) +MAX_AUTH_FAILURES=20 +AUTH_FAILURE_WINDOW=600 # 10 minutes + +# More strict (for high-security) +MAX_AUTH_FAILURES=3 +AUTH_FAILURE_WINDOW=1800 # 30 minutes +``` + +### Key Hashing (Future Enhancement) + +Currently keys are stored in plaintext in `.env`. Future versions will support hashed keys in database. + +**Current security model:** +- āœ… Keys never logged in full (only first 8 + last 4 chars) +- āœ… `.env` file protected by filesystem permissions +- āœ… Container runs as non-root user +- āš ļø Ensure `.env` has restrictive permissions: `chmod 600 .env` + +--- + +## Security Checklist + +Before going to production: + +- [ ] API key generated with `make generate-key` (not manually typed) +- [ ] Key is exactly 64 characters +- [ ] `.env` file has restrictive permissions (`chmod 600 .env`) +- [ ] `.env` is in `.gitignore` (verify: `git status` shows no .env) +- [ ] Container restarted after adding key +- [ ] Authentication enabled (check logs for "ENABLED" message) +- [ ] Test curl command succeeds with key, fails without +- [ ] ChatGPT configured with Authorization header +- [ ] Rate limiting tested (try 6 failed attempts) +- [ ] Audit logs capturing auth events +- [ ] Key rotation reminder set (90 days) +- [ ] Backup key stored securely (password manager) + +--- + +## FAQ + +**Q: Can I use the same key for multiple ChatGPT workspaces?** + +A: Yes, but not recommended. Generate separate keys for auditability: +```bash +MCP_API_KEYS=workspace1-key,workspace2-key +``` + +**Q: How do I revoke access immediately?** + +A: Remove key from `.env` and restart: +```bash +# Edit .env (remove old key) +docker-compose restart aegis-mcp +``` + +Access is revoked instantly after restart. + +**Q: Does rate limiting affect valid keys?** + +A: No. Rate limiting only applies to IPs that fail authentication. Valid keys are never rate limited. + +**Q: Can I see which key was used for each request?** + +A: Yes. Audit logs include `key_hint` (first 8 + last 4 characters): +```json +{"key_hint": "a1b2c3d4...e1f2"} +``` + +**Q: What happens if I lose my API key?** + +A: Generate a new one with `make generate-key`, update `.env`, restart server, update ChatGPT config. The old key stops working immediately after restart. + +--- + +## Next Steps + +- [ChatGPT Setup Guide](CHATGPT_SETUP.md) - Configure ChatGPT Business +- [Key Rotation Guide](KEY_ROTATION.md) - Automated rotation procedures +- [Security Policy](SECURITY.md) - Overall security best practices + +--- + +**Need help?** Open an issue in the Gitea repository. diff --git a/CHATGPT_SETUP.md b/CHATGPT_SETUP.md new file mode 100644 index 0000000..412bf16 --- /dev/null +++ b/CHATGPT_SETUP.md @@ -0,0 +1,435 @@ +# ChatGPT Business Setup Guide + +Complete guide for connecting ChatGPT Business to your secured AegisGitea MCP server. + +--- + +## Prerequisites + +- āœ… AegisGitea MCP server deployed and running +- āœ… API key generated (see [AUTH_SETUP.md](AUTH_SETUP.md)) +- āœ… Traefik configured with HTTPS (or reverse proxy with TLS) +- āœ… ChatGPT Business or Developer subscription + +--- + +## Setup Steps + +### Step 1: Verify MCP Server is Running + +```bash +# Check container status +docker-compose ps + +# Should show "Up" status +aegis-gitea-mcp Up 0.0.0.0:8080->8080/tcp +``` + +### Step 2: Test Authentication + +```bash +# Replace YOUR_API_KEY with your actual key +curl -H "Authorization: Bearer YOUR_API_KEY" \ + https://mcp.yourdomain.com/mcp/tools + +# Expected: JSON response with available tools +# If error: Check AUTH_SETUP.md troubleshooting +``` + +### Step 3: Configure ChatGPT Business + +1. **Open ChatGPT Settings** + - Click your profile icon (bottom left) + - Select "Settings" + - Navigate to "Beta Features" or "Integrations" + +2. **Add MCP Server** + - Look for "Model Context Protocol" or "MCP Servers" + - Click "Add Server" or "+" + +3. **Enter Server Details** + ``` + Name: AegisGitea MCP + URL: https://mcp.yourdomain.com + Type: HTTP/SSE (Server-Sent Events) + ``` + +4. **Add Custom Header** + ``` + Header Name: Authorization + Header Value: Bearer YOUR_API_KEY_HERE + ``` + + **Important:** + - Include the word "Bearer" followed by a space + - Then paste your full 64-character API key + - No quotes around the key + +5. **Save Configuration** + +### Step 4: Test Connection + +Start a new ChatGPT conversation and try: + +``` +List my Gitea repositories +``` + +**Expected Response:** +``` +I found X repositories in your Gitea instance: + +1. org/repo-name - Description here +2. org/another-repo - Another description +... +``` + +**If it fails**, see Troubleshooting section below. + +--- + +## Verification Checklist + +After setup, verify: + +- [ ] ChatGPT shows "Connected" status for AegisGitea MCP +- [ ] Test command "List my Gitea repositories" works +- [ ] Audit logs show successful authentication: + ```bash + docker-compose logs aegis-mcp | grep "api_authentication.*success" + ``` +- [ ] Can read file contents: + ``` + Show me the README.md file from org/repo-name + ``` +- [ ] Can browse repository structure: + ``` + What files are in the src/ directory of org/repo-name? + ``` + +--- + +## Example Commands + +Once connected, try these commands in ChatGPT: + +### Repository Discovery +``` +What repositories do I have access to? +List all my Gitea repositories +Show me my private repositories +``` + +### Repository Information +``` +Tell me about the org/my-repo repository +What's the default branch of org/my-repo? +When was org/my-repo last updated? +``` + +### File Operations +``` +Show me the file tree of org/my-repo +What files are in the src/ directory of org/my-repo? +Read the README.md file from org/my-repo +Show me the contents of src/main.py in org/my-repo +``` + +### Code Understanding +``` +Explain what the main function does in org/my-repo/src/main.py +Summarize the architecture of org/my-repo +What dependencies does org/my-repo use? +Find all TODO comments in org/my-repo +``` + +--- + +## Troubleshooting + +### Issue: "Cannot connect to MCP server" + +**Check 1: Server is running** +```bash +docker-compose ps +docker-compose logs aegis-mcp +``` + +**Check 2: Domain/URL is correct** +```bash +curl https://mcp.yourdomain.com/health +# Should return: {"status": "healthy"} +``` + +**Check 3: Firewall/Network** +- Ensure port 443 is open +- Check Traefik is routing correctly +- Verify DNS resolves correctly + +### Issue: "Authentication failed" + +**Check 1: Authorization header format** + +Correct: +``` +Authorization: Bearer a1b2c3d4e5f6g7h8... +``` + +Wrong: +``` +Authorization: a1b2c3d4... (missing "Bearer ") +Authorization: "Bearer a1b2c3d4..." (extra quotes) +Authorization:Bearer a1b2c3d4... (missing space after colon) +``` + +**Check 2: Key matches .env** +```bash +# Show configured keys (first 12 chars only for security) +docker-compose exec aegis-mcp printenv MCP_API_KEYS | cut -c1-12 +``` + +Compare with your ChatGPT key (first 12 chars). + +**Check 3: Server logs** +```bash +docker-compose logs aegis-mcp | tail -50 +``` + +Look for: +- `invalid_api_key` → Key doesn't match +- `missing_api_key` → Header not sent +- `invalid_key_format` → Key too short + +### Issue: "No repositories visible" + +**Verify bot user has access:** + +1. Log into Gitea +2. Go to repository Settings > Collaborators +3. Confirm bot user is listed with Read permission +4. If not, add bot user as collaborator + +**Test manually:** +```bash +curl -H "Authorization: Bearer YOUR_KEY" \ + https://mcp.yourdomain.com/mcp/tool/call \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{"tool": "list_repositories", "arguments": {}}' +``` + +### Issue: "Rate limited" + +**Symptoms:** +``` +Too many failed authentication attempts +``` + +**Solution:** +1. Wait 5 minutes +2. Verify API key is correct +3. Check audit logs: + ```bash + docker-compose exec aegis-mcp grep "auth_rate_limit" /var/log/aegis-mcp/audit.log + ``` +4. If accidentally locked out: + ```bash + docker-compose restart aegis-mcp + ``` + +### Issue: ChatGPT says "Tool not available" + +**Verify tools are registered:** +```bash +curl -H "Authorization: Bearer YOUR_KEY" \ + https://mcp.yourdomain.com/mcp/tools +``` + +Should return JSON with tools array. + +--- + +## Security Best Practices + +### 1. API Key Management + +- āœ… Store key in password manager +- āœ… Never share key in chat or messages +- āœ… Rotate key every 90 days +- āŒ Don't paste key in public ChatGPT conversations +- āŒ Don't screenshot ChatGPT settings with key visible + +### 2. Workspace Separation + +If multiple team members: + +``` +User A: Use key_a1b2c3... +User B: Use key_b4c5d6... +User C: Use key_c7d8e9... +``` + +Configure in `.env`: +```bash +MCP_API_KEYS=key_a1b2c3...,key_b4c5d6...,key_c7d8e9... +``` + +Each user configures their own ChatGPT with their specific key. + +### 3. Monitoring Usage + +Weekly check: +```bash +# Who's using the MCP server? +docker-compose exec aegis-mcp grep "api_authentication" /var/log/aegis-mcp/audit.log | \ + grep "success" | \ + tail -20 +``` + +Look for `key_hint` to identify which key was used. + +### 4. Incident Response + +If key is compromised: + +1. **Immediate:** Remove from `.env` + ```bash + # Edit .env (remove compromised key) + docker-compose restart aegis-mcp + ``` + +2. **Generate new key** + ```bash + make generate-key + ``` + +3. **Update ChatGPT** with new key + +4. **Check audit logs** for unauthorized access: + ```bash + docker-compose exec aegis-mcp grep "key_hint: " /var/log/aegis-mcp/audit.log + ``` + +--- + +## Advanced Configuration + +### Custom MCP Tool Calls (For Developers) + +You can call tools directly via API: + +```bash +# List repositories +curl -X POST https://mcp.yourdomain.com/mcp/tool/call \ + -H "Authorization: Bearer YOUR_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "tool": "list_repositories", + "arguments": {} + }' + +# Get file contents +curl -X POST https://mcp.yourdomain.com/mcp/tool/call \ + -H "Authorization: Bearer YOUR_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "tool": "get_file_contents", + "arguments": { + "owner": "org", + "repo": "my-repo", + "filepath": "README.md" + } + }' +``` + +### Webhook Integration (Future) + +For automated workflows, you can integrate MCP tools into CI/CD: + +```yaml +# Example GitHub Actions (future enhancement) +- name: Sync to Gitea via MCP + run: | + curl -X POST https://mcp.yourdomain.com/mcp/tool/call \ + -H "Authorization: Bearer ${{ secrets.MCP_API_KEY }}" \ + -H "Content-Type: application/json" \ + -d '{...}' +``` + +--- + +## Performance Tips + +### 1. Rate Limiting Awareness + +Traefik limits: **60 requests/minute** + +For heavy usage: +```yaml +# docker-compose.yml +labels: + - "traefik.http.middlewares.aegis-ratelimit.ratelimit.average=120" +``` + +### 2. Large Files + +Files >1MB are rejected by default. To increase: + +```bash +# .env +MAX_FILE_SIZE_BYTES=5242880 # 5MB +``` + +### 3. Batch Operations + +Instead of: +``` +Show me file1.py +Show me file2.py +Show me file3.py +``` + +Use: +``` +Show me all Python files in the src/ directory +``` + +--- + +## FAQ + +**Q: Can I use this with Claude or other AI assistants?** + +A: Currently optimized for ChatGPT Business, but the MCP protocol is standard. Other AI assistants with MCP support should work with the same configuration. + +**Q: Do I need a separate key for each ChatGPT conversation?** + +A: No. One key per user/workspace. All conversations in that workspace use the same key. + +**Q: Can ChatGPT modify my repositories?** + +A: **No**. AegisGitea MCP is read-only by design. ChatGPT can only read code, never write/commit/push. + +**Q: What happens if I hit the rate limit?** + +A: You'll receive a 429 error. Wait 1 minute and try again. The limit is per IP, not per key. + +**Q: Can I use this from mobile ChatGPT app?** + +A: Yes, if your ChatGPT Business account syncs to mobile. The MCP configuration follows your account. + +**Q: How do I disconnect ChatGPT?** + +A: Go to ChatGPT Settings > MCP Servers > Remove "AegisGitea MCP" + +--- + +## Next Steps + +- [Key Rotation Guide](KEY_ROTATION.md) - Rotate keys every 90 days +- [Authentication Setup](AUTH_SETUP.md) - Detailed auth configuration +- [Security Policy](SECURITY.md) - Best practices and threat model + +--- + +**Happy coding with AI-assisted Gitea access!** šŸš€ diff --git a/KEY_ROTATION.md b/KEY_ROTATION.md new file mode 100644 index 0000000..195d360 --- /dev/null +++ b/KEY_ROTATION.md @@ -0,0 +1,548 @@ +# API Key Rotation Guide + +Comprehensive guide for rotating API keys in AegisGitea MCP with zero downtime. + +--- + +## Why Rotate Keys? + +- **Security hygiene**: Regular rotation limits exposure window +- **Compliance**: Many security policies require quarterly rotation +- **Compromise mitigation**: If a key leaks, rotation limits damage +- **Audit trail**: New keys = clear timeline of when access changed + +**Recommended schedule**: Every 90 days + +--- + +## Automated Rotation (Recommended) + +### Step 1: Check Key Age + +```bash +make check-key-age +``` + +**Output:** +``` +Key: a1b2c3d4... + Created: 2026-01-29 (85 days ago) + Expires: 2026-04-29 (in 5 days) + Status: āš ļø WARNING +``` + +### Step 2: Rotate Key + +```bash +make rotate-key +``` + +This script will: +1. Show current keys +2. Generate new 64-character key +3. Offer rotation strategies (replace or grace period) +4. Backup old `.env` file +5. Update `.env` with new key +6. Provide next steps + +**Example:** +``` +Found 1 existing key(s) + 1. a1b2c3d4...e1f2 + +āœ“ New API key generated! + +Rotation Strategy: + 1. Replace all keys with new key (recommended) + 2. Add new key, keep old keys (grace period) + 3. Cancel + +Choose option [1/2/3]: 2 + +āœ“ New key will be added (total: 2 keys) + +āš ļø IMPORTANT: Remove old keys manually after updating ChatGPT config +``` + +### Step 3: Restart Server + +```bash +docker-compose restart aegis-mcp +``` + +Verify both keys work: +```bash +# Test old key (should still work if using grace period) +curl -H "Authorization: Bearer OLD_KEY" \ + https://mcp.yourdomain.com/mcp/tools + +# Test new key (should work) +curl -H "Authorization: Bearer NEW_KEY" \ + https://mcp.yourdomain.com/mcp/tools +``` + +### Step 4: Update ChatGPT + +1. Go to ChatGPT Settings > MCP Servers +2. Edit "AegisGitea MCP" +3. Update Authorization header: + ``` + Authorization: Bearer NEW_KEY_HERE + ``` +4. Save + +### Step 5: Verify & Clean Up + +Test ChatGPT connection: +``` +List my Gitea repositories +``` + +If successful, remove old key: +```bash +# Edit .env - remove old key, keep only new key +nano .env + +# MCP_API_KEYS=old-key,new-key ← Remove old-key +# MCP_API_KEYS=new-key ← Keep only new key + +# Restart +docker-compose restart aegis-mcp +``` + +Verify old key no longer works: +```bash +curl -H "Authorization: Bearer OLD_KEY" \ + https://mcp.yourdomain.com/mcp/tools +# Should return 401 Unauthorized +``` + +--- + +## Manual Rotation (Step-by-Step) + +If you prefer manual control: + +### 1. Generate New Key + +```bash +make generate-key +``` + +Save the output: +``` +API KEY: b9c8d7e6f5g4h3i2... +``` + +### 2. Add to .env (Grace Period) + +```bash +nano .env + +# Before: +MCP_API_KEYS=a1b2c3d4e5f6g7h8... + +# After (both keys work): +MCP_API_KEYS=a1b2c3d4e5f6g7h8...,b9c8d7e6f5g4h3i2... +``` + +### 3. Restart & Verify + +```bash +docker-compose restart aegis-mcp + +# Check logs +docker-compose logs aegis-mcp | grep "authentication" +# Should show: "API key authentication ENABLED (2 key(s) configured)" +``` + +### 4. Update ChatGPT + +Update Authorization header with new key. + +### 5. Remove Old Key + +After confirming ChatGPT works with new key: + +```bash +nano .env + +# Remove old key +MCP_API_KEYS=b9c8d7e6f5g4h3i2... + +docker-compose restart aegis-mcp +``` + +--- + +## Rotation Strategies + +### Strategy 1: Immediate Replacement (No Downtime Risk) + +**Use when:** You can update ChatGPT config immediately + +1. Generate new key +2. Replace old key in `.env` +3. Restart server +4. Update ChatGPT within minutes + +**Pros:** Clean, only one key at a time +**Cons:** Must update ChatGPT immediately or service breaks + +### Strategy 2: Grace Period (Zero Downtime) + +**Use when:** You need time to update ChatGPT config + +1. Generate new key +2. Add new key, keep old key (both work) +3. Restart server +4. Update ChatGPT at your convenience +5. Remove old key after verification + +**Pros:** Zero downtime, test thoroughly +**Cons:** Temporarily allows two keys + +**Recommended grace period:** 24-48 hours max + +### Strategy 3: Scheduled Maintenance Window + +**Use when:** You want to coordinate with team + +1. Announce maintenance window (e.g., Saturday 2 AM) +2. Generate new key +3. During window: + - Replace key in `.env` + - Restart server + - Update all team members' ChatGPT configs +4. Verify all users can access + +**Pros:** Coordinated, everyone updates at once +**Cons:** Requires coordination + +--- + +## Multi-User Rotation + +If multiple team members use different keys: + +### Option A: Rotate One Key at a Time + +```bash +# Current state +MCP_API_KEYS=alice-key,bob-key,charlie-key + +# Rotate Alice's key +MCP_API_KEYS=alice-new-key,bob-key,charlie-key + +# Alice updates her ChatGPT, verify, continue +``` + +### Option B: Rotate All Keys Simultaneously + +```bash +# Generate 3 new keys +make generate-key # Save as alice-new +make generate-key # Save as bob-new +make generate-key # Save as charlie-new + +# Grace period +MCP_API_KEYS=alice-old,alice-new,bob-old,bob-new,charlie-old,charlie-new + +# Each user updates +# After all verified, remove old keys +MCP_API_KEYS=alice-new,bob-new,charlie-new +``` + +--- + +## Automated Rotation Schedule + +### Setup Cron Job + +```bash +# Edit crontab +crontab -e + +# Check key age weekly +0 9 * * MON /path/to/AegisGitea-MCP/scripts/check_key_age.py || echo "Keys need rotation" + +# Or check daily at 2 AM +0 2 * * * cd /path/to/AegisGitea-MCP && make check-key-age +``` + +Exit codes: +- `0` = All keys OK +- `1` = Warning (rotation recommended in 14 days) +- `2` = Critical (rotation needed NOW) + +### Email Notifications + +```bash +#!/bin/bash +# Save as scripts/check_and_notify.sh + +cd /path/to/AegisGitea-MCP + +# Run check +make check-key-age > /tmp/key_status.txt 2>&1 +STATUS=$? + +if [ $STATUS -eq 2 ]; then + # Critical - send email + mail -s "[CRITICAL] AegisGitea API Key Expired" admin@example.com < /tmp/key_status.txt +elif [ $STATUS -eq 1 ]; then + # Warning - send email + mail -s "[WARNING] AegisGitea API Key Expiring Soon" admin@example.com < /tmp/key_status.txt +fi +``` + +### Discord/Slack Webhook + +```bash +#!/bin/bash +# Save as scripts/notify_discord.sh + +WEBHOOK_URL="https://discord.com/api/webhooks/..." +MESSAGE="āš ļø AegisGitea MCP: API key expires in 5 days. Run \`make rotate-key\`" + +curl -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "{\"content\": \"$MESSAGE\"}" +``` + +--- + +## Emergency Rotation (Compromised Key) + +If you suspect a key has been compromised: + +### Immediate Actions (< 5 minutes) + +1. **Revoke compromised key immediately** + ```bash + # Edit .env - remove compromised key + nano .env + + # Before + MCP_API_KEYS=compromised-key + + # After + MCP_API_KEYS= + + # Or temporarily disable auth + AUTH_ENABLED=false + + docker-compose restart aegis-mcp + ``` + +2. **Check audit logs for unauthorized access** + ```bash + docker-compose exec aegis-mcp grep "compromised_key_hint" /var/log/aegis-mcp/audit.log + + # Look for suspicious IPs, unusual times, unexpected repositories + ``` + +3. **Generate new key** + ```bash + make generate-key + ``` + +4. **Update .env with new key** + ```bash + MCP_API_KEYS=new-secure-key + AUTH_ENABLED=true + + docker-compose restart aegis-mcp + ``` + +5. **Update ChatGPT config** + +### Investigation (< 30 minutes) + +```bash +# When was compromised key last used? +docker-compose exec aegis-mcp grep "key_hint: compromised..." /var/log/aegis-mcp/audit.log | tail -1 + +# What repositories were accessed? +docker-compose exec aegis-mcp grep "compromised..." /var/log/aegis-mcp/audit.log | grep "repository" + +# From which IPs? +docker-compose exec aegis-mcp grep "compromised..." /var/log/aegis-mcp/audit.log | grep "client_ip" +``` + +### Post-Incident (< 24 hours) + +1. Document timeline of compromise +2. Review and improve key storage practices +3. Consider additional security measures (IP allowlisting, MFA) +4. Notify team if multi-user setup + +--- + +## Troubleshooting Rotation + +### Issue: Both old and new keys fail after rotation + +**Cause:** Server not restarted or `.env` syntax error + +**Fix:** +```bash +# Check .env syntax +cat .env | grep MCP_API_KEYS +# Should be: MCP_API_KEYS=key1,key2 + +# No spaces around = +# No quotes around keys +# Comma-separated, no spaces + +# Restart +docker-compose restart aegis-mcp + +# Check logs +docker-compose logs aegis-mcp | tail -20 +``` + +### Issue: "No API keys configured" after rotation + +**Cause:** Empty MCP_API_KEYS in `.env` + +**Fix:** +```bash +# Verify .env has key +grep MCP_API_KEYS .env +# Should NOT be empty + +# If empty, add key +echo "MCP_API_KEYS=your-key-here" >> .env + +docker-compose restart aegis-mcp +``` + +### Issue: Old key still works after removal + +**Cause:** Server not restarted or cached config + +**Fix:** +```bash +# Force restart (not just reload) +docker-compose down +docker-compose up -d + +# Verify old key fails +curl -H "Authorization: Bearer OLD_KEY" \ + https://mcp.yourdomain.com/mcp/tools +# Should return 401 +``` + +### Issue: Lost .env.backup file + +**Cause:** Backup not created during rotation + +**Prevention:** +```bash +# Always backup before manual edits +cp .env .env.backup-$(date +%Y%m%d-%H%M%S) + +# The rotate script does this automatically +``` + +**Recovery:** +If you lost your key and no backup: +1. Generate new key +2. Update `.env` +3. Update ChatGPT +4. The old key is lost but system still works + +--- + +## Best Practices Checklist + +Before rotation: +- [ ] Check current key age: `make check-key-age` +- [ ] Backup `.env`: `cp .env .env.backup` +- [ ] Notify team if multi-user +- [ ] Schedule maintenance window if needed + +During rotation: +- [ ] Generate new key: `make generate-key` +- [ ] Save key securely (password manager) +- [ ] Update `.env` with new key +- [ ] Restart server: `docker-compose restart` +- [ ] Verify new key works (curl test) +- [ ] Update ChatGPT config +- [ ] Test ChatGPT connection + +After rotation: +- [ ] Remove old key from `.env` (if using grace period) +- [ ] Verify old key fails (curl test) +- [ ] Check audit logs for successful auth with new key +- [ ] Save key metadata: Save metadata when generating key +- [ ] Set calendar reminder for next rotation (90 days) +- [ ] Document rotation in changelog/runbook + +--- + +## Key Metadata Tracking + +When generating keys, save metadata: + +```bash +make generate-key +# Choose "y" when prompted to save metadata +``` + +This creates `keys/key--.txt`: +``` +API Key Metadata +================ + +Key ID: a1b2c3d4... +Description: Production ChatGPT Key +Created: 2026-01-29T12:00:00+00:00 +Expires: 2026-04-29T12:00:00+00:00 + +NOTE: The actual API key is NOT stored in this file for security. + Only metadata is saved for reference. +``` + +**Benefits:** +- Track key age automatically +- Audit key lifecycle +- Identify which key is which (if multiple) +- Automated expiration warnings + +--- + +## FAQ + +**Q: What's the grace period for rotation?** + +A: 24-48 hours is recommended. Keep both keys working just long enough to update ChatGPT, then remove the old key. + +**Q: Can I rotate keys without downtime?** + +A: Yes! Use Strategy 2 (Grace Period). Both old and new keys work simultaneously during the transition. + +**Q: How do I know which key is oldest?** + +A: Use `make check-key-age` if you saved metadata. Otherwise, check audit logs for last usage. + +**Q: Should I rotate after a team member leaves?** + +A: Yes, if they had access to the shared key. Or better: use per-user keys and just remove their key. + +**Q: Can I automate the entire rotation?** + +A: Partially. You can automate generation and notification, but ChatGPT config update requires manual action. + +--- + +## Next Steps + +- [Authentication Setup](AUTH_SETUP.md) - Initial setup guide +- [ChatGPT Configuration](CHATGPT_SETUP.md) - Update ChatGPT after rotation +- [Security Policy](SECURITY.md) - Overall security best practices + +--- + +**Set a reminder now:** Rotate your keys 90 days from today! šŸ”‘ diff --git a/Makefile b/Makefile index dca8727..71b6a11 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install install-dev test lint format clean build run docker-build docker-up docker-down docker-logs +.PHONY: help install install-dev test lint format clean build run docker-build docker-up docker-down docker-logs generate-key rotate-key check-key-age help: @echo "AegisGitea MCP - Available Commands" @@ -11,6 +11,11 @@ help: @echo " make format Format code with black" @echo " make clean Remove build artifacts" @echo "" + @echo "API Key Management:" + @echo " make generate-key Generate new API key" + @echo " make rotate-key Rotate existing API key" + @echo " make check-key-age Check API key age and expiration" + @echo "" @echo "Local Execution:" @echo " make run Run server locally (requires .env)" @echo "" @@ -19,6 +24,7 @@ help: @echo " make docker-up Start containers" @echo " make docker-down Stop containers" @echo " make docker-logs View container logs" + @echo " make docker-restart Restart MCP container" @echo "" install: @@ -67,3 +73,12 @@ docker-restart: docker-shell: docker-compose exec aegis-mcp /bin/bash + +generate-key: + python3 scripts/generate_api_key.py + +rotate-key: + python3 scripts/rotate_api_key.py + +check-key-age: + python3 scripts/check_key_age.py diff --git a/docker-compose.yml b/docker-compose.yml index 6ea8bf3..74ffa0f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -# Convenience symlink to docker/docker-compose.yml +# AegisGitea MCP - Docker Compose Configuration # Usage: docker-compose up -d version: '3.8' @@ -22,6 +22,7 @@ services: networks: - aegis-network + - traefik # Connect to Traefik network (if using Traefik) security_opt: - no-new-privileges:true @@ -41,6 +42,36 @@ services: timeout: 10s retries: 3 start_period: 10s + + # Traefik labels for automatic HTTPS and routing + labels: + - "traefik.enable=true" + + # Router configuration + - "traefik.http.routers.aegis-mcp.rule=Host(`${MCP_DOMAIN:-mcp.example.com}`)" + - "traefik.http.routers.aegis-mcp.entrypoints=websecure" + - "traefik.http.routers.aegis-mcp.tls=true" + - "traefik.http.routers.aegis-mcp.tls.certresolver=letsencrypt" + + # Service configuration + - "traefik.http.services.aegis-mcp.loadbalancer.server.port=8080" + + # Rate limiting middleware (60 req/min per IP) + - "traefik.http.middlewares.aegis-ratelimit.ratelimit.average=60" + - "traefik.http.middlewares.aegis-ratelimit.ratelimit.period=1m" + - "traefik.http.middlewares.aegis-ratelimit.ratelimit.burst=10" + + # Security headers middleware + - "traefik.http.middlewares.aegis-security.headers.sslredirect=true" + - "traefik.http.middlewares.aegis-security.headers.stsSeconds=31536000" + - "traefik.http.middlewares.aegis-security.headers.stsIncludeSubdomains=true" + - "traefik.http.middlewares.aegis-security.headers.stsPreload=true" + - "traefik.http.middlewares.aegis-security.headers.contentTypeNosniff=true" + - "traefik.http.middlewares.aegis-security.headers.browserXssFilter=true" + - "traefik.http.middlewares.aegis-security.headers.forceSTSHeader=true" + + # Apply middlewares to router + - "traefik.http.routers.aegis-mcp.middlewares=aegis-ratelimit@docker,aegis-security@docker" volumes: aegis-mcp-logs: @@ -49,3 +80,8 @@ volumes: networks: aegis-network: driver: bridge + + # External Traefik network (create with: docker network create traefik) + # Comment out if not using Traefik + traefik: + external: true diff --git a/scripts/check_key_age.py b/scripts/check_key_age.py new file mode 100755 index 0000000..b5d15b5 --- /dev/null +++ b/scripts/check_key_age.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Check API key age and alert if rotation needed.""" + +import os +import re +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +def main() -> None: + """Check API key age from keys/ metadata directory.""" + print("=" * 70) + print("AegisGitea MCP - API Key Age Check") + print("=" * 70) + print() + + # Check keys directory + keys_dir = Path(__file__).parent.parent / "keys" + + if not keys_dir.exists(): + print("āš ļø No keys/ directory found") + print() + print(" Key metadata is not being tracked.") + print(" Run scripts/generate_api_key.py and save metadata to track key age.") + print() + sys.exit(0) + + # Find all key metadata files + metadata_files = list(keys_dir.glob("key-*.txt")) + + if not metadata_files: + print("āš ļø No key metadata files found in keys/") + print() + print(" Run scripts/generate_api_key.py and save metadata to track key age.") + print() + sys.exit(0) + + print(f"Found {len(metadata_files)} key metadata file(s)") + print() + print("-" * 70) + print() + + now = datetime.now(timezone.utc) + warnings = [] + critical = [] + + for metadata_file in sorted(metadata_files): + with open(metadata_file, "r") as f: + content = f.read() + + # Extract metadata + key_id_match = re.search(r"Key ID:\s+([^\n]+)", content) + created_match = re.search(r"Created:\s+([^\n]+)", content) + expires_match = re.search(r"Expires:\s+([^\n]+)", content) + + if not all([key_id_match, created_match, expires_match]): + print(f"āš ļø Could not parse: {metadata_file.name}") + continue + + key_id = key_id_match.group(1).strip() + created_str = created_match.group(1).strip() + expires_str = expires_match.group(1).strip() + + # Parse dates + try: + created_at = datetime.fromisoformat(created_str.replace("Z", "+00:00")) + expires_at = datetime.fromisoformat(expires_str.replace("Z", "+00:00")) + except ValueError as e: + print(f"āš ļø Could not parse dates in: {metadata_file.name}") + print(f" Error: {e}") + continue + + # Calculate age and time to expiration + age_days = (now - created_at).days + days_to_expiration = (expires_at - now).days + + # Status + if days_to_expiration < 0: + status = "āŒ EXPIRED" + critical.append(key_id) + elif days_to_expiration <= 7: + status = "āš ļø CRITICAL" + critical.append(key_id) + elif days_to_expiration <= 14: + status = "āš ļø WARNING" + warnings.append(key_id) + else: + status = "āœ“ OK" + + print(f"Key: {key_id}") + print(f" Created: {created_at.strftime('%Y-%m-%d')} ({age_days} days ago)") + print(f" Expires: {expires_at.strftime('%Y-%m-%d')} (in {days_to_expiration} days)") + print(f" Status: {status}") + print() + + print("-" * 70) + print() + + # Summary + if critical: + print("āŒ CRITICAL: {} key(s) require immediate rotation!".format(len(critical))) + print() + print(" Keys:") + for key_id in critical: + print(f" • {key_id}") + print() + print(" Action: Run scripts/rotate_api_key.py NOW") + print() + sys.exit(2) + elif warnings: + print("āš ļø WARNING: {} key(s) will expire soon".format(len(warnings))) + print() + print(" Keys:") + for key_id in warnings: + print(f" • {key_id}") + print() + print(" Action: Schedule key rotation in the next few days") + print(" Run: scripts/rotate_api_key.py") + print() + sys.exit(1) + else: + print("āœ“ All keys are valid and within rotation schedule") + print() + print(" Next check: Run this script again in 1 week") + print() + sys.exit(0) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n\nOperation cancelled.") + sys.exit(1) + except Exception as e: + print(f"\nāŒ Error: {e}", file=sys.stderr) + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/scripts/generate_api_key.py b/scripts/generate_api_key.py new file mode 100755 index 0000000..be4aaf5 --- /dev/null +++ b/scripts/generate_api_key.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Generate a cryptographically secure API key for AegisGitea MCP.""" + +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +# Add src to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from aegis_gitea_mcp.auth import generate_api_key + + +def main() -> None: + """Generate and display a new API key.""" + print("=" * 70) + print("AegisGitea MCP - API Key Generator") + print("=" * 70) + print() + + # Get optional description + description = input("Enter description for this key (e.g., 'ChatGPT Business'): ").strip() + if not description: + description = "Generated key" + + # Generate key + api_key = generate_api_key(length=64) + + # Calculate expiration date (90 days from now) + created_at = datetime.now(timezone.utc) + expires_at = created_at + timedelta(days=90) + + print() + print("āœ“ API Key Generated Successfully!") + print() + print("-" * 70) + print(f"Description: {description}") + print(f"Created: {created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}") + print(f"Expires: {expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')} (90 days)") + print("-" * 70) + print() + print("API KEY:") + print(f" {api_key}") + print() + print("-" * 70) + print() + print("šŸ“‹ Next Steps:") + print() + print("1. Add this key to your .env file:") + print() + print(f" MCP_API_KEYS={api_key}") + print() + print(" (If you have multiple keys, separate them with commas)") + print() + print("2. Restart the MCP server:") + print() + print(" docker-compose restart aegis-mcp") + print() + print("3. Configure ChatGPT Business:") + print() + print(" - Go to ChatGPT Settings > MCP Servers") + print(" - Add custom header:") + print(f" Authorization: Bearer {api_key}") + print() + print("4. Test the connection:") + print() + print(" Ask ChatGPT: 'List my Gitea repositories'") + print() + print("-" * 70) + print() + print("āš ļø IMPORTANT:") + print() + print(" • Store this key securely - it won't be shown again") + print(" • This key should be rotated in 90 days") + print(" • Set a reminder to rotate before expiration") + print(" • Never commit this key to version control") + print() + print("=" * 70) + + # Offer to save metadata to file + save = input("\nSave key metadata to keys/ directory? [y/N]: ").strip().lower() + if save == "y": + keys_dir = Path(__file__).parent.parent / "keys" + keys_dir.mkdir(exist_ok=True) + + # Create metadata file + key_id = api_key[:12] + metadata_file = keys_dir / f"key-{key_id}-{created_at.strftime('%Y%m%d')}.txt" + + with open(metadata_file, "w") as f: + f.write(f"API Key Metadata\n") + f.write(f"================\n\n") + f.write(f"Key ID: {key_id}...\n") + f.write(f"Description: {description}\n") + f.write(f"Created: {created_at.isoformat()}\n") + f.write(f"Expires: {expires_at.isoformat()}\n") + f.write(f"\n") + f.write(f"NOTE: The actual API key is NOT stored in this file for security.\n") + f.write(f" Only metadata is saved for reference.\n") + + print(f"\nāœ“ Metadata saved to: {metadata_file}") + print(f"\n (The actual key is NOT saved - only you have it)") + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n\nOperation cancelled.") + sys.exit(1) + except Exception as e: + print(f"\nāŒ Error: {e}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/rotate_api_key.py b/scripts/rotate_api_key.py new file mode 100755 index 0000000..6de9794 --- /dev/null +++ b/scripts/rotate_api_key.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Rotate API key for AegisGitea MCP.""" + +import os +import re +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +# Add src to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from aegis_gitea_mcp.auth import generate_api_key + + +def main() -> None: + """Rotate API key in .env file.""" + print("=" * 70) + print("AegisGitea MCP - API Key Rotation") + print("=" * 70) + print() + + # Find .env file + env_file = Path(__file__).parent.parent / ".env" + + if not env_file.exists(): + print("āŒ Error: .env file not found") + print(f" Expected location: {env_file}") + print() + print(" Please create .env from .env.example first") + sys.exit(1) + + # Read current .env + with open(env_file, "r") as f: + env_content = f.read() + + # Check if MCP_API_KEYS exists + if "MCP_API_KEYS" not in env_content: + print("āŒ Error: MCP_API_KEYS not found in .env file") + print() + print(" Please add MCP_API_KEYS to your .env file first") + sys.exit(1) + + # Extract current key + match = re.search(r"MCP_API_KEYS=([^\n]+)", env_content) + if not match: + print("āŒ Error: Could not parse MCP_API_KEYS from .env") + sys.exit(1) + + current_keys = match.group(1).strip() + key_list = [k.strip() for k in current_keys.split(",") if k.strip()] + + print(f"Found {len(key_list)} existing key(s)") + print() + + # Show current keys (first 8 chars only) + for i, key in enumerate(key_list, 1): + key_hint = f"{key[:8]}...{key[-4:]}" if len(key) >= 12 else "invalid" + print(f" {i}. {key_hint}") + + print() + print("-" * 70) + print() + + # Generate new key + new_key = generate_api_key(length=64) + created_at = datetime.now(timezone.utc) + expires_at = created_at + timedelta(days=90) + + print("āœ“ New API key generated!") + print() + print(f"Created: {created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}") + print(f"Expires: {expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')} (90 days)") + print() + print("-" * 70) + print() + + # Ask what to do with old keys + print("Rotation Strategy:") + print() + print(" 1. Replace all keys with new key (recommended)") + print(" 2. Add new key, keep old keys (grace period)") + print(" 3. Cancel") + print() + + choice = input("Choose option [1/2/3]: ").strip() + + if choice == "3": + print("\nRotation cancelled.") + sys.exit(0) + elif choice == "2": + # Add new key to list + key_list.append(new_key) + new_keys_str = ",".join(key_list) + print("\nāœ“ New key will be added (total: {} keys)".format(len(key_list))) + print("\nāš ļø IMPORTANT: Remove old keys manually after updating ChatGPT config") + elif choice == "1": + # Replace with only new key + new_keys_str = new_key + print("\nāœ“ All old keys will be replaced with new key") + else: + print("\nāŒ Invalid choice. Operation cancelled.") + sys.exit(1) + + print() + print("-" * 70) + print() + + # Update .env file + new_env_content = re.sub( + r"MCP_API_KEYS=([^\n]+)", + f"MCP_API_KEYS={new_keys_str}", + env_content + ) + + # Backup old .env + backup_file = env_file.with_suffix(f".env.backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}") + with open(backup_file, "w") as f: + f.write(env_content) + + print(f"āœ“ Backed up old .env to: {backup_file.name}") + + # Write new .env + with open(env_file, "w") as f: + f.write(new_env_content) + + print(f"āœ“ Updated .env file with new key(s)") + print() + print("-" * 70) + print() + print("šŸ“‹ Next Steps:") + print() + print("1. Restart the MCP server:") + print() + print(" docker-compose restart aegis-mcp") + print() + print("2. Update ChatGPT Business configuration:") + print() + print(" - Go to ChatGPT Settings > MCP Servers") + print(" - Update Authorization header:") + print(f" Authorization: Bearer {new_key}") + print() + print("3. Test the connection:") + print() + print(" Ask ChatGPT: 'List my Gitea repositories'") + print() + print("4. If using grace period (option 2):") + print() + print(" - After confirming ChatGPT works with new key") + print(" - Manually remove old keys from .env") + print(" - Restart server again") + print() + print("-" * 70) + print() + print("āš ļø IMPORTANT:") + print() + print(f" • New API Key: {new_key}") + print(" • Store this securely - it won't be shown again") + print(" • Set a reminder to rotate in 90 days") + print(" • Old .env backed up to:", backup_file.name) + print() + print("=" * 70) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n\nOperation cancelled.") + sys.exit(1) + except Exception as e: + print(f"\nāŒ Error: {e}", file=sys.stderr) + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/src/aegis_gitea_mcp/auth.py b/src/aegis_gitea_mcp/auth.py new file mode 100644 index 0000000..2aaaece --- /dev/null +++ b/src/aegis_gitea_mcp/auth.py @@ -0,0 +1,243 @@ +"""Authentication module for MCP server API key validation.""" + +import hashlib +import hmac +import secrets +from datetime import datetime, timezone +from typing import Optional, Tuple + +from aegis_gitea_mcp.audit import get_audit_logger +from aegis_gitea_mcp.config import get_settings + + +class AuthenticationError(Exception): + """Raised when authentication fails.""" + + pass + + +class APIKeyValidator: + """Validates API keys for MCP server access.""" + + def __init__(self) -> None: + """Initialize API key validator.""" + self.settings = get_settings() + self.audit = get_audit_logger() + self._failed_attempts: dict[str, list[datetime]] = {} + + def _constant_time_compare(self, a: str, b: str) -> bool: + """Compare two strings in constant time to prevent timing attacks. + + Args: + a: First string + b: Second string + + Returns: + True if strings are equal, False otherwise + """ + return hmac.compare_digest(a, b) + + def _check_rate_limit(self, identifier: str) -> bool: + """Check if identifier has exceeded failed authentication rate limit. + + Args: + identifier: IP address or other identifier + + Returns: + True if within rate limit, False if exceeded + """ + now = datetime.now(timezone.utc) + window_start = now.timestamp() - self.settings.auth_failure_window + + # Clean up old attempts + if identifier in self._failed_attempts: + self._failed_attempts[identifier] = [ + attempt + for attempt in self._failed_attempts[identifier] + if attempt.timestamp() > window_start + ] + + # Check count + attempt_count = len(self._failed_attempts.get(identifier, [])) + return attempt_count < self.settings.max_auth_failures + + def _record_failed_attempt(self, identifier: str) -> None: + """Record a failed authentication attempt. + + Args: + identifier: IP address or other identifier + """ + now = datetime.now(timezone.utc) + if identifier not in self._failed_attempts: + self._failed_attempts[identifier] = [] + self._failed_attempts[identifier].append(now) + + # Check if threshold exceeded + if len(self._failed_attempts[identifier]) >= self.settings.max_auth_failures: + self.audit.log_security_event( + event_type="auth_rate_limit_exceeded", + description=f"IP {identifier} exceeded auth failure threshold", + severity="high", + metadata={ + "identifier": identifier, + "failure_count": len(self._failed_attempts[identifier]), + "window_seconds": self.settings.auth_failure_window, + }, + ) + + def validate_api_key( + self, provided_key: Optional[str], client_ip: str, user_agent: str + ) -> Tuple[bool, Optional[str]]: + """Validate an API key. + + Args: + provided_key: API key provided by client + client_ip: Client IP address + user_agent: Client user agent string + + Returns: + Tuple of (is_valid, error_message) + """ + # Check if authentication is enabled + if not self.settings.auth_enabled: + self.audit.log_security_event( + event_type="auth_disabled", + description="Authentication is disabled - allowing all requests", + severity="critical", + metadata={"client_ip": client_ip}, + ) + return True, None + + # Check rate limit + if not self._check_rate_limit(client_ip): + self.audit.log_access_denied( + tool_name="api_authentication", + reason="rate_limit_exceeded", + ) + return False, "Too many failed authentication attempts. Please try again later." + + # Check if key was provided + if not provided_key: + self._record_failed_attempt(client_ip) + self.audit.log_access_denied( + tool_name="api_authentication", + reason="missing_api_key", + ) + return False, "Authorization header missing. Required: Authorization: Bearer " + + # Validate key format (should be at least 32 characters) + if len(provided_key) < 32: + self._record_failed_attempt(client_ip) + self.audit.log_access_denied( + tool_name="api_authentication", + reason="invalid_key_format", + ) + return False, "Invalid API key format" + + # Get valid API keys from config + valid_keys = self.settings.mcp_api_keys + + if not valid_keys: + self.audit.log_security_event( + event_type="no_api_keys_configured", + description="No API keys configured in environment", + severity="critical", + metadata={"client_ip": client_ip}, + ) + return False, "Server configuration error: No API keys configured" + + # Check against all valid keys (constant time comparison) + is_valid = any(self._constant_time_compare(provided_key, valid_key) for valid_key in valid_keys) + + if is_valid: + # Success - log and return + key_hint = f"{provided_key[:8]}...{provided_key[-4:]}" + self.audit.log_tool_invocation( + tool_name="api_authentication", + result_status="success", + params={"client_ip": client_ip, "user_agent": user_agent, "key_hint": key_hint}, + ) + return True, None + else: + # Failure - record attempt and log + self._record_failed_attempt(client_ip) + key_hint = f"{provided_key[:8]}..." if len(provided_key) >= 8 else "too_short" + self.audit.log_access_denied( + tool_name="api_authentication", + reason="invalid_api_key", + ) + self.audit.log_security_event( + event_type="invalid_api_key_attempt", + description=f"Invalid API key attempted from {client_ip}", + severity="medium", + metadata={ + "client_ip": client_ip, + "user_agent": user_agent, + "key_hint": key_hint, + }, + ) + return False, "Invalid API key" + + def extract_bearer_token(self, authorization_header: Optional[str]) -> Optional[str]: + """Extract bearer token from Authorization header. + + Args: + authorization_header: Authorization header value + + Returns: + Extracted token or None if invalid format + """ + if not authorization_header: + return None + + parts = authorization_header.split() + if len(parts) != 2: + return None + + scheme, token = parts + if scheme.lower() != "bearer": + return None + + return token + + +def generate_api_key(length: int = 64) -> str: + """Generate a cryptographically secure API key. + + Args: + length: Length of the key in characters (default: 64) + + Returns: + Generated API key as hex string + """ + return secrets.token_hex(length // 2) + + +def hash_api_key(api_key: str) -> str: + """Hash an API key for secure storage (future use). + + Args: + api_key: Plain text API key + + Returns: + SHA256 hash of the key + """ + return hashlib.sha256(api_key.encode()).hexdigest() + + +# Global validator instance +_validator: Optional[APIKeyValidator] = None + + +def get_validator() -> APIKeyValidator: + """Get or create global API key validator instance.""" + global _validator + if _validator is None: + _validator = APIKeyValidator() + return _validator + + +def reset_validator() -> None: + """Reset global validator instance (primarily for testing).""" + global _validator + _validator = None diff --git a/src/aegis_gitea_mcp/config.py b/src/aegis_gitea_mcp/config.py index 39352c0..2eef6e0 100644 --- a/src/aegis_gitea_mcp/config.py +++ b/src/aegis_gitea_mcp/config.py @@ -1,7 +1,7 @@ """Configuration management for AegisGitea MCP server.""" from pathlib import Path -from typing import Optional +from typing import List, Optional from pydantic import Field, HttpUrl, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -67,6 +67,26 @@ class Settings(BaseSettings): ge=1, ) + # Authentication configuration + auth_enabled: bool = Field( + default=True, + description="Enable API key authentication (disable only for testing)", + ) + mcp_api_keys: List[str] = Field( + default_factory=list, + description="List of valid API keys for MCP access (comma-separated in env)", + ) + max_auth_failures: int = Field( + default=5, + description="Maximum authentication failures before rate limiting", + ge=1, + ) + auth_failure_window: int = Field( + default=300, # 5 minutes + description="Time window for counting auth failures (in seconds)", + ge=1, + ) + @field_validator("log_level") @classmethod def validate_log_level(cls, v: str) -> str: @@ -85,6 +105,41 @@ class Settings(BaseSettings): raise ValueError("gitea_token cannot be empty or whitespace") return v.strip() + @field_validator("mcp_api_keys", mode="before") + @classmethod + def parse_api_keys(cls, v: object) -> List[str]: + """Parse API keys from comma-separated string or list.""" + if isinstance(v, str): + # Split by comma and strip whitespace + keys = [key.strip() for key in v.split(",") if key.strip()] + return keys + elif isinstance(v, list): + return v + return [] + + @field_validator("mcp_api_keys") + @classmethod + def validate_api_keys(cls, v: List[str], info) -> List[str]: + """Validate API keys if authentication is enabled.""" + # Get auth_enabled from values (it's already been processed) + auth_enabled = info.data.get("auth_enabled", True) + + if auth_enabled and not v: + raise ValueError( + "At least one API key must be configured when auth_enabled=True. " + "Set MCP_API_KEYS environment variable or disable auth with AUTH_ENABLED=false" + ) + + # Validate key format (at least 32 characters for security) + for key in v: + if len(key) < 32: + raise ValueError( + f"API keys must be at least 32 characters long. " + f"Use scripts/generate_api_key.py to generate secure keys." + ) + + return v + @property def gitea_base_url(self) -> str: """Get Gitea base URL as string.""" diff --git a/src/aegis_gitea_mcp/server.py b/src/aegis_gitea_mcp/server.py index a381885..6f4167b 100644 --- a/src/aegis_gitea_mcp/server.py +++ b/src/aegis_gitea_mcp/server.py @@ -8,6 +8,7 @@ from fastapi.responses import JSONResponse, StreamingResponse from pydantic import ValidationError from aegis_gitea_mcp.audit import get_audit_logger +from aegis_gitea_mcp.auth import get_validator from aegis_gitea_mcp.config import get_settings from aegis_gitea_mcp.gitea_client import GiteaClient from aegis_gitea_mcp.mcp_protocol import ( @@ -38,9 +39,10 @@ app = FastAPI( version="0.1.0", ) -# Global settings and audit logger +# Global settings, audit logger, and auth validator settings = get_settings() audit = get_audit_logger() +auth_validator = get_validator() # Tool dispatcher mapping @@ -52,6 +54,44 @@ TOOL_HANDLERS = { } +# Authentication middleware +@app.middleware("http") +async def authenticate_request(request: Request, call_next): + """Authenticate all requests except health checks and root.""" + # Skip authentication for health check and root endpoints + if request.url.path in ["/", "/health"]: + return await call_next(request) + + # Only authenticate MCP endpoints + if not request.url.path.startswith("/mcp/"): + return await call_next(request) + + # Extract client information + client_ip = request.client.host if request.client else "unknown" + user_agent = request.headers.get("user-agent", "unknown") + + # Extract Authorization header + auth_header = request.headers.get("authorization") + api_key = auth_validator.extract_bearer_token(auth_header) + + # Validate API key + is_valid, error_message = auth_validator.validate_api_key(api_key, client_ip, user_agent) + + if not is_valid: + return JSONResponse( + status_code=401, + content={ + "error": "Authentication failed", + "message": error_message, + "detail": "Please provide a valid API key in the Authorization header: Bearer ", + }, + ) + + # Authentication successful - continue to endpoint + response = await call_next(request) + return response + + @app.on_event("startup") async def startup_event() -> None: """Initialize server on startup.""" @@ -59,6 +99,13 @@ async def startup_event() -> None: logger.info(f"Connected to Gitea instance: {settings.gitea_base_url}") logger.info(f"Audit logging enabled: {settings.audit_log_path}") + # Log authentication status + if settings.auth_enabled: + key_count = len(settings.mcp_api_keys) + logger.info(f"API key authentication ENABLED ({key_count} key(s) configured)") + else: + logger.warning("API key authentication DISABLED - server is open to all requests!") + # Test Gitea connection try: async with GiteaClient() as gitea: