pki: add minimal certificate authority

- CA generation with encrypted private key storage (AES-256-GCM)
- Client certificate issuance with configurable validity
- Certificate revocation with status tracking
- SHA1 fingerprint integration with existing mTLS auth
- API endpoints: /pki/status, /pki/ca, /pki/issue, /pki/revoke
- CLI commands: fpaste pki status/issue/revoke
- Comprehensive test coverage
This commit is contained in:
Username
2025-12-20 17:20:15 +01:00
parent 7deba711d4
commit 4e38517faf
9 changed files with 3815 additions and 481 deletions

View File

@@ -103,7 +103,7 @@ Host: localhost:5000
```json
{
"name": "FlaskPaste",
"version": "1.1.0",
"version": "1.2.0",
"endpoints": {
"GET /": "API information",
"GET /health": "Health check",
@@ -159,6 +159,45 @@ X-PoW-Solution: 12345678
**Request (Burn-After-Read):**
Create a paste that deletes itself after first retrieval:
```http
POST / HTTP/1.1
Host: localhost:5000
Content-Type: text/plain
X-Burn-After-Read: true
```
**Request (Custom Expiry):**
Create a paste with custom expiry time (in seconds):
```http
POST / HTTP/1.1
Host: localhost:5000
Content-Type: text/plain
X-Expiry: 3600
```
**Request (Password Protected):**
Create a paste that requires a password to access:
```http
POST / HTTP/1.1
Host: localhost:5000
Content-Type: text/plain
X-Paste-Password: secretpassword
```
**Response (201 Created):**
```json
{
"id": "abc12345",
"url": "/abc12345",
"raw": "/abc12345/raw",
"mime_type": "text/plain",
"created_at": 1700000000,
"owner": "a1b2c3...", // Only present if authenticated
@@ -167,7 +206,10 @@ Hello, World!
"password_protected": true // Only present if password set
}
```
**Errors:**
| Code | Description |
|------|-------------|
| 400 | No content provided |
| 400 | Password too long (max 1024 chars) |
| 400 | Proof-of-work required (when PoW enabled) |
@@ -175,6 +217,7 @@ Hello, World!
| 413 | Paste too large |
| 429 | Duplicate content rate limit exceeded |
**Size Limits:**
- Anonymous: 3 MiB (configurable via `FLASKPASTE_MAX_ANON`)
- Authenticated: 50 MiB (configurable via `FLASKPASTE_MAX_AUTH`)
@@ -198,6 +241,13 @@ GET /abc12345 HTTP/1.1
Host: localhost:5000
X-Paste-Password: secretpassword
```
**Response (200 OK):**
```json
{
"id": "abc12345",
"mime_type": "text/plain",
"size": 1234,
"created_at": 1700000000,
"raw": "/abc12345/raw",
"password_protected": true // Only present if protected
@@ -205,7 +255,8 @@ Host: localhost:5000
```
**Errors:**
| Code | Description |
|------|-------------|
| 400 | Invalid paste ID format |
| 401 | Password required |
| 403 | Invalid password |
@@ -213,6 +264,8 @@ Host: localhost:5000
---
### GET /{id}/raw
### HEAD /{id}/raw
Retrieve raw paste content with correct MIME type. HEAD returns headers only (useful for checking MIME type and size without downloading content).
@@ -229,6 +282,13 @@ GET /abc12345/raw HTTP/1.1
Host: localhost:5000
X-Paste-Password: secretpassword
```
**Response (200 OK):**
```http
HTTP/1.1 200 OK
Content-Type: image/png
Content-Disposition: inline
<binary content>
```
@@ -245,6 +305,8 @@ Content-Disposition: inline
---
### DELETE /{id}
Delete a paste. Requires authentication and ownership.
**Request:**
@@ -306,6 +368,135 @@ Pastes expire based on last access time (default: 5 days).
```bash
# Paste expires in 1 hour
curl -H "X-Expiry: 3600" --data-binary @file.txt http://host/
```
**Configuration:**
```bash
export FLASKPASTE_EXPIRY=432000 # Default expiry: 5 days
export FLASKPASTE_MAX_EXPIRY=2592000 # Max custom expiry: 30 days
```
**Notes:**
- Custom expiry is capped at `FLASKPASTE_MAX_EXPIRY`
- Invalid or negative values are ignored (uses default)
- Response includes `expires_at` timestamp when custom expiry is set
---
## Burn-After-Read
Single-access pastes that delete themselves after first retrieval.
**How it works:**
- Set `X-Burn-After-Read: true` header on creation
- First `GET /{id}/raw` returns content and deletes paste
- Subsequent requests return 404
- Metadata `GET /{id}` does not trigger burn
- `HEAD` requests do not trigger burn
**Usage:**
```bash
# Create burn-after-read paste
curl -H "X-Burn-After-Read: true" --data-binary @secret.txt http://host/
# Response indicates burn is enabled
{
"id": "abc12345",
"burn_after_read": true,
...
}
```
**Notes:**
- Response includes `X-Burn-After-Read: true` header when content is retrieved
- Can be combined with custom expiry (paste expires OR burns, whichever first)
- Accepted values: `true`, `1`, `yes` (case-insensitive)
---
## Password Protection
Pastes can be protected with a password using PBKDF2-HMAC-SHA256 hashing.
**Creating a protected paste:**
```http
POST / HTTP/1.1
Host: localhost:5000
Content-Type: text/plain
X-Paste-Password: mysecretpassword
```
**Response (201 Created):**
```json
{
"id": "abc12345",
"url": "/abc12345",
"raw": "/abc12345/raw",
"mime_type": "text/plain",
"created_at": 1700000000,
"password_protected": true
}
```
**Accessing protected paste:**
```http
GET /abc12345 HTTP/1.1
Host: localhost:5000
X-Paste-Password: mysecretpassword
```
**Errors:**
| Code | Description |
|------|-------------|
| 400 | Password too long (max 1024 chars) |
| 401 | Password required |
| 403 | Invalid password |
**Response (401 Unauthorized):**
```json
{
"error": "Password required",
"password_protected": true
}
```
**Response (403 Forbidden):**
```json
{
"error": "Invalid password"
}
```
**Security Implementation:**
- PBKDF2-HMAC-SHA256 with 600,000 iterations (OWASP 2023)
- 32-byte random salt per password
- Constant-time comparison prevents timing attacks
- Passwords never logged or stored in plaintext
- Maximum 1024 characters to prevent DoS via expensive hashing
**CLI Usage:**
```bash
# Create protected paste
./fpaste create -p "mypassword" secret.txt
# Retrieve protected paste
./fpaste get -p "mypassword" abc12345
```
**Notes:**
- Password protection can be combined with burn-after-read and custom expiry
- Unicode passwords are supported
- Special characters are allowed
---
## Abuse Prevention
FlaskPaste includes content-hash based deduplication to prevent spam and abuse.
**How it works:**
- Each paste's SHA256 content hash is tracked
- Repeated submissions of identical content are throttled
- After exceeding the threshold, further duplicates are rejected with 429
@@ -513,3 +704,235 @@ http-request set-header X-Proxy-Secret your-secret-value
FlaskPaste includes an optional minimal PKI for issuing client certificates.
### Configuration
Enable PKI via environment variables:
```bash
export FLASKPASTE_PKI_ENABLED=1 # Enable PKI endpoints
export FLASKPASTE_PKI_CA_PASSWORD="secret" # Required: CA password
export FLASKPASTE_PKI_CERT_DAYS=365 # Client certificate validity (days)
export FLASKPASTE_PKI_CA_DAYS=3650 # CA certificate validity (days)
```
### Certificate Revocation
When PKI is enabled, certificates issued by the CA are tracked. Revoked certificates are rejected during authentication (treated as anonymous).
---
### GET /pki
Get PKI status and CA information.
**Request:**
```http
GET /pki HTTP/1.1
Host: localhost:5000
```
**Response (PKI disabled):**
```json
{
"enabled": false
}
```
**Response (PKI enabled, no CA):**
```json
{
"enabled": true,
"ca_exists": false,
"hint": "POST /pki/ca to generate CA"
}
```
**Response (PKI enabled with CA):**
```json
{
"enabled": true,
"ca_exists": true,
"common_name": "FlaskPaste CA",
"fingerprint_sha1": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"created_at": 1700000000,
"expires_at": 2015000000,
"key_algorithm": "ec:secp384r1"
}
```
---
### POST /pki/ca
Generate a new Certificate Authority. Only works once (first-run bootstrap).
**Request:**
```http
POST /pki/ca HTTP/1.1
Host: localhost:5000
Content-Type: application/json
```
**Response (201 Created):**
```json
{
"message": "CA generated",
"common_name": "My CA",
"fingerprint_sha1": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"created_at": 1700000000,
"expires_at": 2015000000,
"download": "/pki/ca.crt"
}
```
**Errors:**
| Code | Description |
|------|-------------|
| 404 | PKI not enabled |
| 409 | CA already exists |
| 500 | PKI_CA_PASSWORD not configured |
---
### GET /pki/ca.crt
Download CA certificate in PEM format (for trust store).
**Request:**
```http
GET /pki/ca.crt HTTP/1.1
Host: localhost:5000
```
**Response (200 OK):**
```
-----BEGIN CERTIFICATE-----
MIICxDCCAaygAwIBAgIUY...
-----END CERTIFICATE-----
```
Content-Type: `application/x-pem-file`
**Errors:**
| Code | Description |
|------|-------------|
| 404 | PKI not enabled or CA not initialized |
---
### POST /pki/issue
Issue a new client certificate (open registration).
**Request:**
```http
POST /pki/issue HTTP/1.1
Host: localhost:5000
Content-Type: application/json
```
**Response (201 Created):**
```json
{
"message": "Certificate issued",
"serial": "00000000000000000000000000000001",
"common_name": "alice",
"fingerprint_sha1": "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3",
"created_at": 1700000000,
"expires_at": 1731536000,
"certificate_pem": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n",
"private_key_pem": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
}
```
**Errors:**
| Code | Description |
|------|-------------|
| 400 | common_name required |
| 404 | PKI not enabled or CA not initialized |
| 500 | Certificate issuance failed |
**Security Notes:**
- Private key is generated server-side and returned in response
- Store the private key securely; it is not recoverable
- The certificate can be used with nginx, HAProxy, or curl for mTLS
---
### GET /pki/certs
List certificates (own certificates only).
**Request (authenticated):**
```http
GET /pki/certs HTTP/1.1
Host: localhost:5000
X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
```
**Response (200 OK):**
```json
{
"certificates": [
{
"serial": "00000000000000000000000000000001",
"common_name": "alice",
"fingerprint_sha1": "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3",
"created_at": 1700000000,
"expires_at": 1731536000,
"status": "valid",
"issued_to": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
}
],
"count": 1
}
```
**Notes:**
- Anonymous users receive an empty list
- Users see certificates they issued or certificates that match their fingerprint
---
### POST /pki/revoke/{serial}
Revoke a certificate by serial number. Requires authentication and ownership.
**Request:**
```http
POST /pki/revoke/00000000000000000000000000000001 HTTP/1.1
Host: localhost:5000
X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
```
**Response (200 OK):**
```json
{
"message": "Certificate revoked",
"serial": "00000000000000000000000000000001"
}
```
**Errors:**
| Code | Description |
|------|-------------|
| 401 | Authentication required |
| 403 | Permission denied (not owner) |
| 404 | Certificate not found or PKI not enabled |
| 409 | Certificate already revoked |
**Authorization:**
- Must be authenticated
- Can revoke certificates you issued (issued_to matches your fingerprint)
- Can revoke your own certificate (fingerprint matches)
| 401 | Authentication required |
| 403 | Permission denied (not owner) |
| 404 | Certificate not found or PKI not enabled |
| 409 | Certificate already revoked |
**Authorization:**
- Must be authenticated
- Can revoke certificates you issued (issued_to matches your fingerprint)
- Can revoke your own certificate (fingerprint matches)