forked from claw/flaskpaste
add /register endpoint for public certificate registration
Public endpoint allows anyone to obtain a client certificate for authentication. Features: - Higher PoW difficulty than paste creation (24 vs 20 bits) - Auto-generates CA on first registration if not present - Returns PKCS#12 bundle with cert, key, and CA - Configurable via FLASKPASTE_REGISTER_POW Endpoints: - GET /register/challenge - Get registration PoW challenge - POST /register - Register and receive PKCS#12 bundle
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -31,6 +31,7 @@ data/*.db-shm
|
|||||||
.credentials.json
|
.credentials.json
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
keys/
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ python run.py
|
|||||||
| `GET /<id>/raw` | Retrieve raw paste content |
|
| `GET /<id>/raw` | Retrieve raw paste content |
|
||||||
| `HEAD /<id>/raw` | Retrieve paste headers (no body) |
|
| `HEAD /<id>/raw` | Retrieve paste headers (no body) |
|
||||||
| `DELETE /<id>` | Delete paste (requires auth) |
|
| `DELETE /<id>` | Delete paste (requires auth) |
|
||||||
|
| `GET /register/challenge` | Get PoW challenge for registration |
|
||||||
|
| `POST /register` | Register and get client certificate (PKCS#12) |
|
||||||
|
|
||||||
## Usage Examples
|
## Usage Examples
|
||||||
|
|
||||||
@@ -230,6 +232,7 @@ Configuration via environment variables:
|
|||||||
| `FLASKPASTE_PROXY_SECRET` | (empty) | Shared secret for proxy trust validation |
|
| `FLASKPASTE_PROXY_SECRET` | (empty) | Shared secret for proxy trust validation |
|
||||||
| `FLASKPASTE_POW_DIFFICULTY` | `20` | PoW difficulty (leading zero bits, 0=disabled) |
|
| `FLASKPASTE_POW_DIFFICULTY` | `20` | PoW difficulty (leading zero bits, 0=disabled) |
|
||||||
| `FLASKPASTE_POW_TTL` | `300` (5 min) | PoW challenge validity period |
|
| `FLASKPASTE_POW_TTL` | `300` (5 min) | PoW challenge validity period |
|
||||||
|
| `FLASKPASTE_REGISTER_POW` | `24` | Registration PoW difficulty (higher than paste creation) |
|
||||||
| `FLASKPASTE_POW_SECRET` | (auto) | Secret for signing PoW challenges |
|
| `FLASKPASTE_POW_SECRET` | (auto) | Secret for signing PoW challenges |
|
||||||
| `FLASKPASTE_ANTIFLOOD` | `1` | Enable anti-flood (dynamic PoW difficulty) |
|
| `FLASKPASTE_ANTIFLOOD` | `1` | Enable anti-flood (dynamic PoW difficulty) |
|
||||||
| `FLASKPASTE_ANTIFLOOD_WINDOW` | `60` | Anti-flood measurement window (seconds) |
|
| `FLASKPASTE_ANTIFLOOD_WINDOW` | `60` | Anti-flood measurement window (seconds) |
|
||||||
|
|||||||
@@ -364,12 +364,19 @@ def get_pow_secret() -> bytes:
|
|||||||
return _pow_secret_cache
|
return _pow_secret_cache
|
||||||
|
|
||||||
|
|
||||||
def generate_challenge() -> dict[str, Any]:
|
def generate_challenge(difficulty_override: int | None = None) -> dict[str, Any]:
|
||||||
"""Generate new PoW challenge with signed token.
|
"""Generate new PoW challenge with signed token.
|
||||||
|
|
||||||
Uses dynamic difficulty which may be elevated during high load.
|
Uses dynamic difficulty which may be elevated during high load,
|
||||||
|
unless difficulty_override is specified.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
difficulty_override: Optional fixed difficulty (for registration)
|
||||||
"""
|
"""
|
||||||
difficulty = get_dynamic_difficulty()
|
if difficulty_override is not None:
|
||||||
|
difficulty = difficulty_override
|
||||||
|
else:
|
||||||
|
difficulty = get_dynamic_difficulty()
|
||||||
ttl = current_app.config["POW_CHALLENGE_TTL"]
|
ttl = current_app.config["POW_CHALLENGE_TTL"]
|
||||||
expires = int(time.time()) + ttl
|
expires = int(time.time()) + ttl
|
||||||
nonce = secrets.token_hex(16)
|
nonce = secrets.token_hex(16)
|
||||||
@@ -385,14 +392,25 @@ def generate_challenge() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def verify_pow(token: str, solution: str) -> tuple[bool, str]:
|
def verify_pow(
|
||||||
|
token: str, solution: str, min_difficulty: int | None = None
|
||||||
|
) -> tuple[bool, str]:
|
||||||
"""Verify proof-of-work solution. Returns (valid, error_message).
|
"""Verify proof-of-work solution. Returns (valid, error_message).
|
||||||
|
|
||||||
Accepts tokens with difficulty >= base. The solution must meet the
|
Accepts tokens with difficulty >= min_difficulty. The solution must meet the
|
||||||
token's embedded difficulty (which may be elevated due to anti-flood).
|
token's embedded difficulty (which may be elevated due to anti-flood).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: PoW challenge token
|
||||||
|
solution: Nonce solution
|
||||||
|
min_difficulty: Minimum required difficulty (defaults to POW_DIFFICULTY)
|
||||||
"""
|
"""
|
||||||
base_difficulty = current_app.config["POW_DIFFICULTY"]
|
base_difficulty = current_app.config["POW_DIFFICULTY"]
|
||||||
if base_difficulty == 0:
|
if base_difficulty == 0 and min_difficulty is None:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
required_difficulty = min_difficulty if min_difficulty is not None else base_difficulty
|
||||||
|
if required_difficulty == 0:
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
# Parse token
|
# Parse token
|
||||||
@@ -416,9 +434,9 @@ def verify_pow(token: str, solution: str) -> tuple[bool, str]:
|
|||||||
if int(time.time()) > expires:
|
if int(time.time()) > expires:
|
||||||
return False, "Challenge expired"
|
return False, "Challenge expired"
|
||||||
|
|
||||||
# Token difficulty must be at least base (anti-flood may have raised it)
|
# Token difficulty must be at least the required difficulty
|
||||||
if token_diff < base_difficulty:
|
if token_diff < required_difficulty:
|
||||||
return False, "Difficulty too low"
|
return False, f"Difficulty too low: {token_diff} < {required_difficulty}"
|
||||||
|
|
||||||
# Verify solution
|
# Verify solution
|
||||||
try:
|
try:
|
||||||
@@ -806,6 +824,153 @@ class ChallengeView(MethodView):
|
|||||||
return json_response(response)
|
return json_response(response)
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterChallengeView(MethodView):
|
||||||
|
"""Registration PoW challenge endpoint (higher difficulty)."""
|
||||||
|
|
||||||
|
def get(self) -> Response:
|
||||||
|
"""Generate PoW challenge for registration (higher difficulty)."""
|
||||||
|
register_difficulty = current_app.config.get("REGISTER_POW_DIFFICULTY", 24)
|
||||||
|
if register_difficulty == 0:
|
||||||
|
return json_response({"enabled": False, "difficulty": 0})
|
||||||
|
|
||||||
|
ch = generate_challenge(difficulty_override=register_difficulty)
|
||||||
|
return json_response(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"nonce": ch["nonce"],
|
||||||
|
"difficulty": ch["difficulty"],
|
||||||
|
"expires": ch["expires"],
|
||||||
|
"token": ch["token"],
|
||||||
|
"purpose": "registration",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterView(MethodView):
|
||||||
|
"""Public client certificate registration endpoint."""
|
||||||
|
|
||||||
|
def post(self) -> Response:
|
||||||
|
"""Register and obtain a client certificate.
|
||||||
|
|
||||||
|
Requires PoW to prevent abuse. Returns PKCS#12 bundle with:
|
||||||
|
- Client certificate
|
||||||
|
- Client private key
|
||||||
|
- CA certificate
|
||||||
|
|
||||||
|
Auto-generates CA if not present and PKI_CA_PASSWORD is configured.
|
||||||
|
"""
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
|
||||||
|
from app.pki import (
|
||||||
|
CANotFoundError,
|
||||||
|
PKIError,
|
||||||
|
create_pkcs12,
|
||||||
|
generate_ca,
|
||||||
|
get_ca_info,
|
||||||
|
issue_certificate,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check PKI configuration
|
||||||
|
password = current_app.config.get("PKI_CA_PASSWORD", "")
|
||||||
|
if not password:
|
||||||
|
return error_response(
|
||||||
|
"Registration not available",
|
||||||
|
503,
|
||||||
|
hint="PKI_CA_PASSWORD not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify PoW
|
||||||
|
register_difficulty = current_app.config.get("REGISTER_POW_DIFFICULTY", 24)
|
||||||
|
if register_difficulty > 0:
|
||||||
|
token = request.headers.get("X-PoW-Token", "")
|
||||||
|
solution = request.headers.get("X-PoW-Solution", "")
|
||||||
|
|
||||||
|
if not token or not solution:
|
||||||
|
return error_response(
|
||||||
|
"Proof-of-work required",
|
||||||
|
400,
|
||||||
|
hint="GET /register/challenge for a registration challenge",
|
||||||
|
difficulty=register_difficulty,
|
||||||
|
)
|
||||||
|
|
||||||
|
valid, err = verify_pow(token, solution, min_difficulty=register_difficulty)
|
||||||
|
if not valid:
|
||||||
|
current_app.logger.warning(
|
||||||
|
"Registration PoW failed: %s from=%s", err, request.remote_addr
|
||||||
|
)
|
||||||
|
return error_response(f"Proof-of-work failed: {err}", 400)
|
||||||
|
|
||||||
|
# Parse common_name from request
|
||||||
|
common_name = None
|
||||||
|
if request.is_json:
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if data and isinstance(data.get("common_name"), str):
|
||||||
|
common_name = data["common_name"][:64].strip()
|
||||||
|
|
||||||
|
if not common_name:
|
||||||
|
# Generate random common name if not provided
|
||||||
|
common_name = f"client-{secrets.token_hex(4)}"
|
||||||
|
|
||||||
|
# Auto-generate CA if needed
|
||||||
|
if get_ca_info() is None:
|
||||||
|
ca_days = current_app.config.get("PKI_CA_DAYS", 3650)
|
||||||
|
try:
|
||||||
|
ca_info = generate_ca("FlaskPaste CA", password, days=ca_days)
|
||||||
|
current_app.logger.info(
|
||||||
|
"CA auto-generated for registration: fingerprint=%s",
|
||||||
|
ca_info["fingerprint_sha1"][:12],
|
||||||
|
)
|
||||||
|
except PKIError as e:
|
||||||
|
current_app.logger.error("CA auto-generation failed: %s", e)
|
||||||
|
return error_response("CA generation failed", 500)
|
||||||
|
|
||||||
|
# Issue certificate
|
||||||
|
try:
|
||||||
|
cert_days = current_app.config.get("PKI_CERT_DAYS", 365)
|
||||||
|
cert_info = issue_certificate(common_name, password, days=cert_days)
|
||||||
|
except CANotFoundError:
|
||||||
|
return error_response("CA not available", 500)
|
||||||
|
except PKIError as e:
|
||||||
|
current_app.logger.error("Certificate issuance failed: %s", e)
|
||||||
|
return error_response("Certificate issuance failed", 500)
|
||||||
|
|
||||||
|
# Load certificates for PKCS#12 creation
|
||||||
|
ca_info = get_ca_info()
|
||||||
|
ca_cert = x509.load_pem_x509_certificate(ca_info["certificate_pem"].encode())
|
||||||
|
client_cert = x509.load_pem_x509_certificate(
|
||||||
|
cert_info["certificate_pem"].encode()
|
||||||
|
)
|
||||||
|
client_key = serialization.load_pem_private_key(
|
||||||
|
cert_info["private_key_pem"].encode(), password=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create PKCS#12 bundle (no password for easy import)
|
||||||
|
p12_data = create_pkcs12(
|
||||||
|
private_key=client_key,
|
||||||
|
certificate=client_cert,
|
||||||
|
ca_certificate=ca_cert,
|
||||||
|
friendly_name=common_name,
|
||||||
|
password=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
current_app.logger.info(
|
||||||
|
"Client registered: cn=%s fingerprint=%s from=%s",
|
||||||
|
common_name,
|
||||||
|
cert_info["fingerprint_sha1"][:12],
|
||||||
|
request.remote_addr,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return PKCS#12 as binary download
|
||||||
|
response = Response(p12_data, mimetype="application/x-pkcs12")
|
||||||
|
response.headers["Content-Disposition"] = (
|
||||||
|
f'attachment; filename="{common_name}.p12"'
|
||||||
|
)
|
||||||
|
response.headers["X-Fingerprint-SHA1"] = cert_info["fingerprint_sha1"]
|
||||||
|
response.headers["X-Certificate-Expires"] = str(cert_info["expires_at"])
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
class ClientView(MethodView):
|
class ClientView(MethodView):
|
||||||
"""CLI client download endpoint."""
|
"""CLI client download endpoint."""
|
||||||
|
|
||||||
@@ -1481,6 +1646,12 @@ bp.add_url_rule("/health", view_func=HealthView.as_view("health"))
|
|||||||
bp.add_url_rule("/challenge", view_func=ChallengeView.as_view("challenge"))
|
bp.add_url_rule("/challenge", view_func=ChallengeView.as_view("challenge"))
|
||||||
bp.add_url_rule("/client", view_func=ClientView.as_view("client"))
|
bp.add_url_rule("/client", view_func=ClientView.as_view("client"))
|
||||||
|
|
||||||
|
# Registration endpoints (public certificate issuance with PoW)
|
||||||
|
bp.add_url_rule(
|
||||||
|
"/register/challenge", view_func=RegisterChallengeView.as_view("register_challenge")
|
||||||
|
)
|
||||||
|
bp.add_url_rule("/register", view_func=RegisterView.as_view("register"))
|
||||||
|
|
||||||
# Paste operations
|
# Paste operations
|
||||||
bp.add_url_rule("/pastes", view_func=PastesListView.as_view("pastes_list"))
|
bp.add_url_rule("/pastes", view_func=PastesListView.as_view("pastes_list"))
|
||||||
bp.add_url_rule("/<paste_id>", view_func=PasteView.as_view("paste"), methods=["GET", "HEAD", "PUT"])
|
bp.add_url_rule("/<paste_id>", view_func=PasteView.as_view("paste"), methods=["GET", "HEAD", "PUT"])
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ class Config:
|
|||||||
POW_CHALLENGE_TTL = int(os.environ.get("FLASKPASTE_POW_TTL", "300")) # 5 minutes
|
POW_CHALLENGE_TTL = int(os.environ.get("FLASKPASTE_POW_TTL", "300")) # 5 minutes
|
||||||
# Secret key for signing challenges (auto-generated if not set)
|
# Secret key for signing challenges (auto-generated if not set)
|
||||||
POW_SECRET = os.environ.get("FLASKPASTE_POW_SECRET", "")
|
POW_SECRET = os.environ.get("FLASKPASTE_POW_SECRET", "")
|
||||||
|
# Registration PoW difficulty (higher than paste creation for security)
|
||||||
|
REGISTER_POW_DIFFICULTY = int(os.environ.get("FLASKPASTE_REGISTER_POW", "24"))
|
||||||
|
|
||||||
# Anti-flood: dynamically increase PoW difficulty under load
|
# Anti-flood: dynamically increase PoW difficulty under load
|
||||||
ANTIFLOOD_ENABLED = os.environ.get("FLASKPASTE_ANTIFLOOD", "1").lower() in (
|
ANTIFLOOD_ENABLED = os.environ.get("FLASKPASTE_ANTIFLOOD", "1").lower() in (
|
||||||
|
|||||||
44
app/pki.py
44
app/pki.py
@@ -31,6 +31,7 @@ try:
|
|||||||
from cryptography.hazmat.primitives.asymmetric import ec
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||||
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
|
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
|
||||||
|
|
||||||
HAS_CRYPTO = True
|
HAS_CRYPTO = True
|
||||||
@@ -189,6 +190,49 @@ def calculate_fingerprint(certificate: Any) -> str:
|
|||||||
return hashlib.sha1(cert_der, usedforsecurity=False).hexdigest()
|
return hashlib.sha1(cert_der, usedforsecurity=False).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def create_pkcs12(
|
||||||
|
private_key: Any,
|
||||||
|
certificate: Any,
|
||||||
|
ca_certificate: Any,
|
||||||
|
friendly_name: str,
|
||||||
|
password: bytes | None = None,
|
||||||
|
) -> bytes:
|
||||||
|
"""Create PKCS#12 bundle containing certificate, private key, and CA cert.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
private_key: Client private key object
|
||||||
|
certificate: Client certificate object
|
||||||
|
ca_certificate: CA certificate object
|
||||||
|
friendly_name: Friendly name for the certificate
|
||||||
|
password: Optional password for PKCS#12 encryption (None = no password)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PKCS#12 bytes (DER encoded)
|
||||||
|
"""
|
||||||
|
_require_crypto()
|
||||||
|
|
||||||
|
# Build encryption algorithm - use strong encryption if password provided
|
||||||
|
if password:
|
||||||
|
encryption = (
|
||||||
|
serialization.BestAvailableEncryption(password)
|
||||||
|
if password
|
||||||
|
else serialization.NoEncryption()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
encryption = serialization.NoEncryption()
|
||||||
|
|
||||||
|
# Serialize to PKCS#12
|
||||||
|
p12_data = pkcs12.serialize_key_and_certificates(
|
||||||
|
name=friendly_name.encode("utf-8"),
|
||||||
|
key=private_key,
|
||||||
|
cert=certificate,
|
||||||
|
cas=[ca_certificate],
|
||||||
|
encryption_algorithm=encryption,
|
||||||
|
)
|
||||||
|
|
||||||
|
return p12_data
|
||||||
|
|
||||||
|
|
||||||
class PKI:
|
class PKI:
|
||||||
"""Standalone PKI manager for CA and certificate operations.
|
"""Standalone PKI manager for CA and certificate operations.
|
||||||
|
|
||||||
|
|||||||
@@ -1049,3 +1049,124 @@ X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
|
|||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
```http
|
```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-Certificate-Fingerprint: b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3
|
||||||
|
|
||||||
|
<binary PKCS#12 data>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response Headers:**
|
||||||
|
| Header | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `X-Certificate-Fingerprint` | SHA1 fingerprint for `X-SSL-Client-SHA1` header |
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Tests for PKI (Certificate Authority) functionality."""
|
"""Tests for PKI (Certificate Authority) functionality."""
|
||||||
|
|
||||||
from datetime import UTC
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -369,3 +369,228 @@ class TestPKICryptoFunctions:
|
|||||||
|
|
||||||
assert len(fingerprint) == 40
|
assert len(fingerprint) == 40
|
||||||
assert all(c in "0123456789abcdef" for c in fingerprint)
|
assert all(c in "0123456789abcdef" for c in fingerprint)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegistration:
|
||||||
|
"""Test public certificate registration via /register endpoint."""
|
||||||
|
|
||||||
|
def test_register_challenge_returns_token(self, client, app):
|
||||||
|
"""Registration challenge endpoint returns PoW token."""
|
||||||
|
with app.app_context():
|
||||||
|
app.config["REGISTER_POW_DIFFICULTY"] = 10
|
||||||
|
|
||||||
|
response = client.get("/register/challenge")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["enabled"] is True
|
||||||
|
assert "nonce" in data
|
||||||
|
assert "token" in data
|
||||||
|
assert data["difficulty"] == 10
|
||||||
|
assert data["purpose"] == "registration"
|
||||||
|
|
||||||
|
def test_register_challenge_disabled(self, client, app):
|
||||||
|
"""Registration challenge shows disabled when difficulty is 0."""
|
||||||
|
with app.app_context():
|
||||||
|
app.config["REGISTER_POW_DIFFICULTY"] = 0
|
||||||
|
|
||||||
|
response = client.get("/register/challenge")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["enabled"] is False
|
||||||
|
|
||||||
|
def test_register_requires_pow(self, client, app):
|
||||||
|
"""Registration fails without PoW when difficulty > 0."""
|
||||||
|
with app.app_context():
|
||||||
|
app.config["REGISTER_POW_DIFFICULTY"] = 10
|
||||||
|
|
||||||
|
response = client.post("/register", json={"common_name": "test"})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "Proof-of-work required" in response.get_json()["error"]
|
||||||
|
|
||||||
|
def test_register_with_pow_disabled_succeeds(self, client, app):
|
||||||
|
"""Registration succeeds without PoW when difficulty is 0."""
|
||||||
|
with app.app_context():
|
||||||
|
app.config["REGISTER_POW_DIFFICULTY"] = 0
|
||||||
|
|
||||||
|
response = client.post("/register", json={"common_name": "test-client"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.content_type == "application/x-pkcs12"
|
||||||
|
assert "X-Fingerprint-SHA1" in response.headers
|
||||||
|
assert len(response.headers["X-Fingerprint-SHA1"]) == 40
|
||||||
|
|
||||||
|
def test_register_auto_generates_ca(self, client, app):
|
||||||
|
"""Registration auto-generates CA if not present."""
|
||||||
|
with app.app_context():
|
||||||
|
app.config["REGISTER_POW_DIFFICULTY"] = 0
|
||||||
|
|
||||||
|
# Verify no CA exists
|
||||||
|
from app.pki import get_ca_info
|
||||||
|
|
||||||
|
assert get_ca_info() is None
|
||||||
|
|
||||||
|
response = client.post("/register", json={"common_name": "first-client"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Verify CA now exists
|
||||||
|
with app.app_context():
|
||||||
|
from app.pki import get_ca_info
|
||||||
|
|
||||||
|
ca_info = get_ca_info()
|
||||||
|
assert ca_info is not None
|
||||||
|
assert ca_info["common_name"] == "FlaskPaste CA"
|
||||||
|
|
||||||
|
def test_register_returns_pkcs12(self, client, app):
|
||||||
|
"""Registration returns valid PKCS#12 bundle."""
|
||||||
|
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
app.config["REGISTER_POW_DIFFICULTY"] = 0
|
||||||
|
|
||||||
|
response = client.post("/register", json={"common_name": "my-client"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Verify PKCS#12 can be loaded
|
||||||
|
p12_data = response.data
|
||||||
|
private_key, certificate, additional_certs = pkcs12.load_key_and_certificates(
|
||||||
|
p12_data, password=None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert private_key is not None
|
||||||
|
assert certificate is not None
|
||||||
|
# Should include CA certificate
|
||||||
|
assert additional_certs is not None
|
||||||
|
assert len(additional_certs) == 1
|
||||||
|
|
||||||
|
def test_register_generates_common_name(self, client, app):
|
||||||
|
"""Registration generates random CN if not provided."""
|
||||||
|
with app.app_context():
|
||||||
|
app.config["REGISTER_POW_DIFFICULTY"] = 0
|
||||||
|
|
||||||
|
response = client.post("/register")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# CN is in the Content-Disposition header
|
||||||
|
disposition = response.headers["Content-Disposition"]
|
||||||
|
assert "client-" in disposition
|
||||||
|
assert ".p12" in disposition
|
||||||
|
|
||||||
|
def test_register_respects_custom_common_name(self, client, app):
|
||||||
|
"""Registration uses provided common name."""
|
||||||
|
with app.app_context():
|
||||||
|
app.config["REGISTER_POW_DIFFICULTY"] = 0
|
||||||
|
|
||||||
|
response = client.post("/register", json={"common_name": "custom-name"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
disposition = response.headers["Content-Disposition"]
|
||||||
|
assert "custom-name.p12" in disposition
|
||||||
|
|
||||||
|
def test_register_without_pki_password_fails(self, client, app):
|
||||||
|
"""Registration fails when PKI_CA_PASSWORD not configured."""
|
||||||
|
with app.app_context():
|
||||||
|
app.config["PKI_CA_PASSWORD"] = ""
|
||||||
|
app.config["REGISTER_POW_DIFFICULTY"] = 0
|
||||||
|
|
||||||
|
response = client.post("/register", json={"common_name": "test"})
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert "not available" in response.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestPKCS12Creation:
|
||||||
|
"""Test PKCS#12 bundle creation function."""
|
||||||
|
|
||||||
|
def test_create_pkcs12_without_password(self):
|
||||||
|
"""PKCS#12 created without password can be loaded."""
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
|
||||||
|
from app.pki import create_pkcs12
|
||||||
|
|
||||||
|
# Generate test keys and certs
|
||||||
|
ca_key = ec.generate_private_key(ec.SECP384R1())
|
||||||
|
client_key = ec.generate_private_key(ec.SECP384R1())
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
ca_cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Test CA")]))
|
||||||
|
.issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Test CA")]))
|
||||||
|
.public_key(ca_key.public_key())
|
||||||
|
.serial_number(1)
|
||||||
|
.not_valid_before(now)
|
||||||
|
.not_valid_after(now + timedelta(days=365))
|
||||||
|
.sign(ca_key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
|
||||||
|
client_cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Client")]))
|
||||||
|
.issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Test CA")]))
|
||||||
|
.public_key(client_key.public_key())
|
||||||
|
.serial_number(2)
|
||||||
|
.not_valid_before(now)
|
||||||
|
.not_valid_after(now + timedelta(days=30))
|
||||||
|
.sign(ca_key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create PKCS#12
|
||||||
|
p12_data = create_pkcs12(
|
||||||
|
private_key=client_key,
|
||||||
|
certificate=client_cert,
|
||||||
|
ca_certificate=ca_cert,
|
||||||
|
friendly_name="test-client",
|
||||||
|
password=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load and verify
|
||||||
|
loaded_key, loaded_cert, loaded_cas = pkcs12.load_key_and_certificates(
|
||||||
|
p12_data, password=None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert loaded_key is not None
|
||||||
|
assert loaded_cert is not None
|
||||||
|
assert loaded_cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value == "Client"
|
||||||
|
assert loaded_cas is not None
|
||||||
|
assert len(loaded_cas) == 1
|
||||||
|
|
||||||
|
def test_create_pkcs12_with_password(self):
|
||||||
|
"""PKCS#12 created with password requires password to load."""
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
|
||||||
|
from app.pki import create_pkcs12
|
||||||
|
|
||||||
|
key = ec.generate_private_key(ec.SECP384R1())
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Test")]))
|
||||||
|
.issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Test")]))
|
||||||
|
.public_key(key.public_key())
|
||||||
|
.serial_number(1)
|
||||||
|
.not_valid_before(now)
|
||||||
|
.not_valid_after(now + timedelta(days=1))
|
||||||
|
.sign(key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
|
||||||
|
p12_data = create_pkcs12(
|
||||||
|
private_key=key,
|
||||||
|
certificate=cert,
|
||||||
|
ca_certificate=cert,
|
||||||
|
friendly_name="test",
|
||||||
|
password=b"secret123",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should fail without password
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
pkcs12.load_key_and_certificates(p12_data, password=None)
|
||||||
|
|
||||||
|
# Should succeed with correct password
|
||||||
|
loaded_key, _, _ = pkcs12.load_key_and_certificates(p12_data, password=b"secret123")
|
||||||
|
assert loaded_key is not None
|
||||||
|
|||||||
Reference in New Issue
Block a user