Files
flaskpaste/documentation/api.md
Username 30bd02663b
Some checks failed
CI / Lint & Format (push) Failing after 16s
CI / Unit Tests (push) Has been skipped
CI / Memory Leak Check (push) Has been skipped
CI / SBOM Generation (push) Has been skipped
CI / Security Scan (push) Successful in 20s
CI / Security Tests (push) Has been skipped
CI / Advanced Security Tests (push) Has been skipped
add /csr endpoint for CSR signing
Allow clients to submit Certificate Signing Requests instead of
having the server generate private keys. Client keeps key local.

- sign_csr() in pki.py validates and signs CSRs
- POST /csr endpoint with PoW protection
- 10 new tests for CSR functionality
- API documentation updated
2025-12-26 21:44:29 +01:00

1378 lines
32 KiB
Markdown

# FlaskPaste API Reference
## Overview
FlaskPaste provides a RESTful API for creating, retrieving, and deleting text and binary pastes.
**Base URL:** `http://your-server:5000/`
**Content Types:**
- Requests: `application/json`, `text/plain`, `application/octet-stream`, or any binary type
- Responses: `application/json` for metadata, original MIME type for raw content
## Authentication
Authentication is optional and uses client certificate fingerprints passed via the `X-SSL-Client-SHA1` header.
```http
X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
```
The fingerprint must be exactly 40 lowercase hexadecimal characters (SHA1).
**Benefits of authentication:**
- Larger upload limit (50 MiB vs 3 MiB)
- Longer default expiry (7-30 days vs 1 day)
- Ability to list and delete owned pastes
- Register via PKI for trusted status and admin eligibility
---
## Endpoints
### GET /challenge
Get a proof-of-work challenge for paste creation. Required when PoW is enabled.
**Request:**
```http
GET /challenge HTTP/1.1
Host: localhost:5000
```
**Response (PoW disabled):**
```json
{
"enabled": false,
"difficulty": 0
}
```
**Response (PoW enabled):**
```json
{
"enabled": true,
"nonce": "a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8",
"difficulty": 20,
"base_difficulty": 20,
"elevated": false,
"expires": 1700000300,
"token": "a1b2c3d4...:1700000300:20:signature"
}
```
**Response (PoW enabled, anti-flood active):**
```json
{
"enabled": true,
"nonce": "a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8",
"difficulty": 24,
"base_difficulty": 20,
"elevated": true,
"expires": 1700000300,
"token": "a1b2c3d4...:1700000300:24:signature"
}
```
The client must find a number `N` such that `SHA256(nonce + ":" + N)` has at least `difficulty` leading zero bits.
When `elevated` is true, the difficulty has been increased due to high request volume (anti-flood protection). The `base_difficulty` shows the normal difficulty level.
---
### GET /health
Health check endpoint for load balancers and monitoring.
**Request:**
```http
GET /health HTTP/1.1
Host: localhost:5000
```
**Response (200 OK):**
```json
{
"status": "healthy",
"database": "ok"
}
```
**Response (503 Service Unavailable):**
```json
{
"status": "unhealthy",
"database": "error"
}
```
---
### GET /
Returns API information and usage instructions.
**Request:**
```http
GET / HTTP/1.1
Host: localhost:5000
```
**Response:**
```json
{
"name": "FlaskPaste",
"version": "1.5.0",
"endpoints": {
"GET /": "API information",
"GET /health": "Health check",
"POST /": "Create paste",
"GET /<id>": "Retrieve paste metadata",
"GET /<id>/raw": "Retrieve raw paste content",
"DELETE /<id>": "Delete paste"
},
"usage": {
"raw": "curl --data-binary @file.txt http://host/",
"pipe": "cat file.txt | curl --data-binary @- http://host/",
"json": "curl -H 'Content-Type: application/json' -d '{\"content\":\"...\"}' http://host/"
},
"note": "Use --data-binary (not -d) to preserve newlines"
}
```
---
### GET /pastes
List pastes for the authenticated user. Requires authentication.
**Request:**
```http
GET /pastes HTTP/1.1
Host: localhost:5000
X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
```
**Query Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `limit` | int | Maximum results (default: 100, max: 1000) |
| `offset` | int | Skip first N results (default: 0) |
| `type` | string | Filter by MIME type (glob pattern, e.g., `image/*`) |
| `after` | int | Created after timestamp (Unix epoch) |
| `before` | int | Created before timestamp (Unix epoch) |
| `all` | int | List all pastes (admin only, set to 1) |
**Response (200 OK):**
```json
{
"pastes": [
{
"id": "a1b2c3d4e5f6",
"size": 1234,
"mime_type": "text/plain",
"created_at": "2024-12-20T10:30:00Z",
"expires_at": "2024-12-25T10:30:00Z",
"burn_after_read": false,
"password_protected": false,
"owner": "a1b2c3d4..."
}
],
"total": 42,
"limit": 100,
"offset": 0
}
```
**Response (401 Unauthorized):**
```json
{
"error": "Authentication required"
}
```
**Response (403 Forbidden - non-admin using `all=1`):**
```json
{
"error": "Admin access required"
}
```
**Notes:**
- Only admin users can use `all=1` to list all pastes
- The `owner` field shows the certificate fingerprint (truncated)
- First user to register via PKI becomes admin
---
### POST /
Create a new paste.
**Request (Raw Binary):**
```http
POST / HTTP/1.1
Host: localhost:5000
Content-Type: application/octet-stream
```
**Request (JSON):**
```http
POST / HTTP/1.1
Host: localhost:5000
Content-Type: application/json
```
**Request (with Proof-of-Work):**
When PoW is enabled, include the challenge token and solution:
```http
POST / HTTP/1.1
Host: localhost:5000
Content-Type: text/plain
X-PoW-Token: a1b2c3d4...:1700000300:20:signature
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
"burn_after_read": true, // Only present if enabled
"expires_at": 1700003600, // Only present if custom expiry set
"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) |
| 400 | Proof-of-work failed (invalid/expired challenge) |
| 400 | Paste too small (below minimum size) |
| 413 | Paste too large |
| 429 | Duplicate content rate limit exceeded |
| 429 | Rate limit exceeded (per-IP throttling) |
**Size Limits:**
- Minimum: disabled by default (`FLASKPASTE_MIN_SIZE`, e.g. 64 bytes for encryption enforcement)
- Anonymous: 3 MiB (configurable via `FLASKPASTE_MAX_ANON`)
- Authenticated: 50 MiB (configurable via `FLASKPASTE_MAX_AUTH`)
**Rate Limit Headers:**
When rate limiting is enabled, responses include standard rate limit headers:
| Header | Description |
|--------|-------------|
| `X-RateLimit-Limit` | Maximum requests per window |
| `X-RateLimit-Remaining` | Remaining requests in current window |
| `X-RateLimit-Reset` | Unix timestamp when window resets |
These headers appear on both successful (201) and rate-limited (429) responses:
```http
HTTP/1.1 201 Created
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 9
X-RateLimit-Reset: 1700000060
```
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 45
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1700000060
```
Rate limits are per-IP and configurable:
- `FLASKPASTE_RATE_MAX`: Base limit (default: 10 requests/minute)
- `FLASKPASTE_RATE_AUTH_MULT`: Multiplier for authenticated users (default: 5x)
---
### GET /{id}
### HEAD /{id}
Retrieve paste metadata. HEAD returns headers only (no body).
**Request:**
```http
GET /abc12345 HTTP/1.1
Host: localhost:5000
```
**Request (Password Protected):**
```http
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
}
```
**Errors:**
| Code | Description |
|------|-------------|
| 400 | Invalid paste ID format |
| 401 | Password required |
| 403 | Invalid password |
| 404 | Paste not found |
---
### 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).
**Request:**
```http
GET /abc12345/raw HTTP/1.1
Host: localhost:5000
```
**Request (Password Protected):**
```http
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>
```
- `Content-Disposition: inline` is set for `image/*` and `text/*` types
- Content-Type matches the detected/stored MIME type
**Errors:**
| Code | Description |
|------|-------------|
| 400 | Invalid paste ID format |
| 401 | Password required |
| 403 | Invalid password |
| 404 | Paste not found |
---
### DELETE /{id}
Delete a paste. Requires authentication and ownership (or admin rights).
**Request:**
```http
DELETE /abc12345 HTTP/1.1
Host: localhost:5000
X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
```
**Response (200 OK):**
```json
{
"message": "Paste deleted"
}
```
**Errors:**
| Code | Description |
|------|-------------|
| 400 | Invalid paste ID format |
| 401 | Authentication required |
| 403 | Permission denied (not owner or admin) |
| 404 | Paste not found |
**Notes:**
- Admin users can delete any paste, not just their own
- First user to register via PKI becomes admin
---
## MIME Type Detection
FlaskPaste automatically detects MIME types using:
1. **Magic byte signatures** (highest priority)
| Category | Formats |
|----------|---------|
| Images | PNG, JPEG, GIF, WebP, BMP, TIFF, ICO |
| Video | MP4, WebM, FLV, Matroska |
| Audio | MP3, FLAC, OGG |
| Documents | PDF, MS Office (DOC/XLS/PPT), ZIP-based (DOCX/XLSX/ODT) |
| Executables | EXE/DLL (PE), ELF (Linux), Mach-O (macOS), WASM |
| Archives | ZIP, GZIP, BZIP2, XZ, ZSTD, LZ4, 7z, RAR |
| Data | SQLite |
2. **Explicit Content-Type header** (if not generic)
3. **UTF-8 detection** (falls back to `text/plain`)
4. **Binary fallback** (`application/octet-stream`)
---
## Paste Expiry
Pastes automatically expire based on authentication level:
| Trust Level | Default Expiry | Description |
|-------------|----------------|-------------|
| Anonymous | 1 day | No certificate provided |
| Untrusted | 7 days | Certificate not registered via PKI |
| Trusted | 30 days | Certificate registered via `/register` |
- Every `GET /{id}` or `GET /{id}/raw` updates the last access timestamp
- Cleanup runs automatically (hourly, throttled)
**Custom Expiry:**
Pastes can have custom expiry times using the `X-Expiry` header:
```bash
# Paste expires in 1 hour
curl -H "X-Expiry: 3600" --data-binary @file.txt http://host/
```
**Configuration:**
```bash
export FLASKPASTE_EXPIRY_ANON=86400 # Anonymous: 1 day
export FLASKPASTE_EXPIRY_UNTRUSTED=604800 # Untrusted cert: 7 days
export FLASKPASTE_EXPIRY_TRUSTED=2592000 # Trusted cert: 30 days
export FLASKPASTE_MAX_EXPIRY=7776000 # Max custom expiry: 90 days
```
**Notes:**
- Custom expiry is capped at `FLASKPASTE_MAX_EXPIRY`
- Invalid or negative values use the default for the user's trust level
- All pastes now include `expires_at` timestamp in responses
---
## 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
**Default limits:**
- Window: 1 hour (`FLASKPASTE_DEDUP_WINDOW`)
- Maximum: 3 identical submissions per window (`FLASKPASTE_DEDUP_MAX`)
**Response (429 Too Many Requests):**
```json
{
"error": "Duplicate content rate limit exceeded",
"count": 3,
"window_seconds": 3600
}
```
**Configuration:**
```bash
export FLASKPASTE_DEDUP_WINDOW=3600 # Window in seconds (default: 1 hour)
export FLASKPASTE_DEDUP_MAX=3 # Max duplicates per window (default: 3)
```
**Notes:**
- Different content is not affected by other content's limits
- Counter resets after the window expires
- Hash records are cleaned up periodically
---
## Entropy Enforcement
FlaskPaste can require minimum content entropy to enforce client-side encryption.
**How it works:**
- Shannon entropy is calculated for submitted content (bits per byte)
- Encrypted/random data: ~7.5-8.0 bits/byte
- English text: ~4.0-5.0 bits/byte
- Content below threshold is rejected with 400
**Configuration:**
```bash
export FLASKPASTE_MIN_ENTROPY=6.0 # Require encryption-level entropy (0=disabled)
export FLASKPASTE_MIN_ENTROPY_SIZE=256 # Only check content >= this size (default: 256)
```
**Response (400 Bad Request):**
```json
{
"error": "Content entropy too low",
"entropy": 4.12,
"min_entropy": 7.0,
"hint": "Encrypt content before uploading (fpaste encrypts by default)"
}
```
**Caveats:**
- Small data is exempt (configurable via `MIN_ENTROPY_SIZE`, default 256 bytes)
- Compressed data (gzip, zip) also has high entropy — not distinguishable from encrypted
- This is a heuristic, not cryptographic proof of encryption
**Recommended thresholds:**
| Threshold | Effect |
|-----------|--------|
| 0 | Disabled (default) |
| 5.0 | Blocks most plaintext |
| 6.0 | Requires encryption or compression |
| 7.0 | Requires encryption + sufficient size |
---
### Binary Content Requirement
FlaskPaste can require unrecognizable binary content (encryption enforcement).
**How it works:**
- Content is checked for valid UTF-8 text
- Plaintext (valid UTF-8) is rejected with 400
- Only binary content (invalid UTF-8) is allowed
**Configuration:**
```bash
export FLASKPASTE_REQUIRE_BINARY=1 # Reject plaintext (0=disabled)
```
**Response (400 Bad Request):**
```json
{
"error": "Recognizable format not allowed",
"detected": "text/plain",
"hint": "Encrypt content before uploading (fpaste encrypts by default)"
}
```
**vs Entropy enforcement:**
| Method | Detects | Use case |
|--------|---------|----------|
| Entropy | Low-entropy data | Reject unencrypted content |
| Binary | Valid UTF-8 text | Reject plaintext |
Use both together for maximum encryption enforcement:
```bash
export FLASKPASTE_REQUIRE_BINARY=1
export FLASKPASTE_MIN_ENTROPY=6.0
export FLASKPASTE_MIN_SIZE=64
```
---
## Proof-of-Work
FlaskPaste includes an optional proof-of-work system to prevent automated spam.
**How it works:**
1. Client requests a challenge via `GET /challenge`
2. Server returns a nonce, difficulty, expiry time, and signed token
3. Client computes SHA256 hashes until finding one with enough leading zero bits
4. Client submits paste with `X-PoW-Token` and `X-PoW-Solution` headers
**Algorithm:**
```
For N = 0, 1, 2, ...:
hash = SHA256(nonce + ":" + N)
if leading_zero_bits(hash) >= difficulty:
return N as solution
```
**Configuration:**
```bash
export FLASKPASTE_POW_DIFFICULTY=20 # Leading zero bits required (0=disabled)
export FLASKPASTE_POW_TTL=300 # Challenge validity in seconds
export FLASKPASTE_POW_SECRET="..." # Optional signing key (auto-generated if empty)
```
**Headers:**
| Header | Description |
|--------|-------------|
| `X-PoW-Token` | The full token from `/challenge` response |
| `X-PoW-Solution` | The computed solution number |
**Error responses:**
```json
{
"error": "Proof-of-work required",
"hint": "GET /challenge for a new challenge"
}
```
```json
{
"error": "Proof-of-work failed: Challenge expired"
}
```
**Notes:**
- Difficulty 20 requires approximately 1 million hash attempts on average
- Challenges are signed to prevent tampering
- Each challenge can only be used once (nonce uniqueness)
---
## Error Response Format
All errors return JSON:
```json
{
"error": "Description of the error"
}
```
For size limit errors (413):
```json
{
"error": "Paste too large",
"size": 5000000,
"max_size": 3145728,
"authenticated": false
}
```
---
## Security Headers
All responses include the following security headers:
| Header | Value |
|--------|-------|
| `X-Content-Type-Options` | `nosniff` |
| `X-Frame-Options` | `DENY` |
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
| `Content-Security-Policy` | `default-src 'none'; frame-ancestors 'none'` |
| `Permissions-Policy` | `geolocation=(), microphone=(), camera=()` |
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` |
| `Cache-Control` | `no-store, no-cache, must-revalidate, private` |
| `Pragma` | `no-cache` |
---
## Request Tracing
All requests include an `X-Request-ID` header for log correlation:
- If the client provides `X-Request-ID`, it is passed through
- If not provided, a UUID is generated
- The ID is echoed back in the response
- All log entries include `[rid=<request-id>]`
**Example:**
```bash
# Client-provided ID
curl -H "X-Request-ID: my-trace-123" https://paste.example.com/health
# Response includes:
# X-Request-ID: my-trace-123
```
---
## Proxy Trust Validation
When `FLASKPASTE_PROXY_SECRET` is configured, the application validates that requests come from a trusted reverse proxy by checking the `X-Proxy-Secret` header.
This provides defense-in-depth against header spoofing if an attacker bypasses the reverse proxy.
**Configuration:**
```bash
export FLASKPASTE_PROXY_SECRET="your-secret-value"
```
**Proxy Configuration (HAProxy):**
```
http-request set-header X-Proxy-Secret your-secret-value
```
If the secret doesn't match, authentication headers (`X-SSL-Client-SHA1`) are ignored and the request is treated as anonymous.
---
## PKI (Certificate Authority)
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)
---
### GET /register/challenge
Get a proof-of-work challenge for public certificate registration. Returns a challenge with higher difficulty than paste creation.
**Request:**
```http
GET /register/challenge HTTP/1.1
Host: localhost:5000
```
**Response (PoW disabled):**
```json
{
"enabled": false,
"difficulty": 0
}
```
**Response (PoW enabled):**
```json
{
"enabled": true,
"nonce": "a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8",
"difficulty": 24,
"expires": 1700000300,
"token": "a1b2c3d4...:1700000300:24:signature"
}
```
**Notes:**
- Registration difficulty defaults to 24 bits (vs 20 for paste creation)
- Higher difficulty protects against automated certificate harvesting
- Configurable via `FLASKPASTE_REGISTER_POW` environment variable
---
### POST /register
Register for a client certificate (public endpoint with PoW protection).
This endpoint allows anyone to obtain a client certificate for authentication. The CA is auto-generated on first registration if it doesn't exist.
**Request (with PoW):**
```http
POST /register HTTP/1.1
Host: localhost:5000
Content-Type: application/json
X-PoW-Token: a1b2c3d4...:1700000300:24:signature
X-PoW-Solution: 12345678
```
**Request (PoW disabled):**
```http
POST /register HTTP/1.1
Host: localhost:5000
Content-Type: application/json
```
**Response (200 OK):**
```http
HTTP/1.1 200 OK
Content-Type: application/x-pkcs12
Content-Disposition: attachment; filename="client.p12"
X-Fingerprint-SHA1: b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3
X-Certificate-Expires: 1731533400
<binary PKCS#12 data>
```
**Response Headers:**
| Header | Description |
|--------|-------------|
| `X-Fingerprint-SHA1` | SHA1 fingerprint for `X-SSL-Client-SHA1` header |
| `X-Certificate-Expires` | Unix timestamp when certificate expires |
**PKCS#12 Bundle Contents:**
- Client certificate (signed by CA)
- Client private key (EC secp384r1)
- CA certificate (for trust chain)
**Errors:**
| Code | Description |
|------|-------------|
| 400 | Proof-of-work required (when enabled) |
| 400 | Proof-of-work failed (invalid/expired challenge) |
| 500 | PKI_CA_PASSWORD not configured |
| 500 | Certificate generation failed |
**Configuration:**
```bash
export FLASKPASTE_REGISTER_POW=24 # Registration PoW difficulty (0=disabled)
export FLASKPASTE_PKI_CA_PASSWORD="..." # Required for certificate signing
export FLASKPASTE_PKI_CERT_DAYS=365 # Client certificate validity
export FLASKPASTE_PKI_CA_DAYS=3650 # CA certificate validity (auto-generated)
```
**Using the Certificate:**
```bash
# Extract certificate and key from PKCS#12
openssl pkcs12 -in client.p12 -clcerts -nokeys -out client.crt
openssl pkcs12 -in client.p12 -nocerts -nodes -out client.key
# Use with curl
curl --cert client.crt --key client.key https://paste.example.com/
# Use fingerprint for header-based auth (behind reverse proxy)
curl -H "X-SSL-Client-SHA1: $(openssl x509 -in client.crt -fingerprint -sha1 -noout | cut -d= -f2 | tr -d :)" \
https://paste.example.com/pastes
```
**Notes:**
- `common_name` is optional; a random UUID is generated if omitted
- The PKCS#12 bundle has no password (empty password)
- CA is auto-generated on first registration if not present
- Private key is generated server-side and included in response
---
### POST /csr
Sign a Certificate Signing Request (CSR). Unlike `/register`, this endpoint allows clients to keep their private keys local.
**How it works:**
1. Client generates a key pair locally
2. Client creates a CSR with their public key
3. Client submits CSR to server (with PoW if enabled)
4. Server signs CSR and returns the certificate
5. Client combines their private key with the signed certificate
**Request (with PoW):**
```http
POST /csr HTTP/1.1
Host: localhost:5000
Content-Type: application/x-pem-file
X-PoW-Token: a1b2c3d4...:1700000300:24:signature
X-PoW-Solution: 12345678
-----BEGIN CERTIFICATE REQUEST-----
MIIBhDCB7gIBADBFMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExEjAQBgNVBAcM
...
-----END CERTIFICATE REQUEST-----
```
**Request (PoW disabled):**
```http
POST /csr HTTP/1.1
Host: localhost:5000
Content-Type: application/x-pem-file
-----BEGIN CERTIFICATE REQUEST-----
...
-----END CERTIFICATE REQUEST-----
```
**Response (200 OK):**
```http
HTTP/1.1 200 OK
Content-Type: application/x-pem-file
Content-Disposition: attachment; filename="alice.crt"
X-Fingerprint-SHA1: b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3
X-Certificate-Expires: 1731533400
X-Certificate-Serial: 00000000000000000000000000000002
X-Is-Admin: 0
-----BEGIN CERTIFICATE-----
MIICxDCCAaygAwIBAgIUY...
-----END CERTIFICATE-----
```
**Response Headers:**
| Header | Description |
|--------|-------------|
| `X-Fingerprint-SHA1` | SHA1 fingerprint for `X-SSL-Client-SHA1` header |
| `X-Certificate-Expires` | Unix timestamp when certificate expires |
| `X-Certificate-Serial` | Certificate serial number |
| `X-Is-Admin` | `1` if first user (admin), `0` otherwise |
**Errors:**
| Code | Description |
|------|-------------|
| 400 | CSR required (empty body) |
| 400 | Invalid CSR format |
| 400 | CSR must contain a Common Name (CN) |
| 400 | CSR signature is invalid |
| 400 | Proof-of-work required (when enabled) |
| 400 | Proof-of-work failed (invalid/expired challenge) |
| 503 | PKI_CA_PASSWORD not configured |
**Generating a CSR:**
```bash
# Generate key pair and CSR with OpenSSL
openssl ecparam -genkey -name secp384r1 -out client.key
openssl req -new -key client.key -out client.csr -subj "/CN=alice"
# Submit CSR to server
curl -X POST --data-binary @client.csr \
-H "Content-Type: application/x-pem-file" \
https://paste.example.com/csr > client.crt
# Verify the certificate
openssl x509 -in client.crt -noout -subject -issuer
```
**Creating a PKCS#12 bundle:**
```bash
# Combine key and certificate for browser/curl use
openssl pkcs12 -export -out client.p12 \
-inkey client.key -in client.crt \
-certfile ca.crt -passout pass:
# Use with curl
curl --cert client.crt --key client.key https://paste.example.com/
```
**Comparison with /register:**
| Feature | /register | /csr |
|---------|-----------|------|
| Private key | Generated server-side | Generated client-side |
| Response format | PKCS#12 bundle | PEM certificate only |
| Key security | Transmitted over network | Never leaves client |
| Complexity | Simple (one step) | More steps required |
**Notes:**
- Uses same PoW difficulty as `/register` (`FLASKPASTE_REGISTER_POW`)
- CA is auto-generated on first CSR if not present
- First user (via `/register` or `/csr`) becomes admin
- CSR must be signed (server validates signature)
- Common Name (CN) in CSR becomes certificate subject
---
## Audit Logging
FlaskPaste logs PKI certificate lifecycle events for compliance and forensics.
**Logged Events:**
| Event | Trigger | Details |
|-------|---------|---------|
| `cert_issued` | Certificate registration or issuance | Type, CN, fingerprint, expiry |
| `cert_revoked` | Certificate revocation | Serial, fingerprint |
| `auth_failure` | Revoked/expired certificate used | Fingerprint, reason |
**Log Format (production):**
```json
{
"time": "2024-12-24T10:30:00",
"level": "INFO",
"logger": "app.audit",
"event": "cert_issued",
"outcome": "success",
"client_id": "a1b2c3d4...",
"client_ip": "192.168.1.100",
"details": {"type": "registration", "common_name": "alice"}
}
```
**Notes:**
- Audit logs are written to stdout in JSON format (production mode)
- Events include client IP and certificate fingerprint for traceability
- AUTH_FAILURE events are logged when revoked/expired certificates are used