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:
Username
2025-12-21 10:34:02 +01:00
parent 68d51c5b3e
commit 5849c7406f
7 changed files with 577 additions and 10 deletions

View File

@@ -364,12 +364,19 @@ def get_pow_secret() -> bytes:
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.
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"]
expires = int(time.time()) + ttl
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).
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).
Args:
token: PoW challenge token
solution: Nonce solution
min_difficulty: Minimum required difficulty (defaults to 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, ""
# Parse token
@@ -416,9 +434,9 @@ def verify_pow(token: str, solution: str) -> tuple[bool, str]:
if int(time.time()) > expires:
return False, "Challenge expired"
# Token difficulty must be at least base (anti-flood may have raised it)
if token_diff < base_difficulty:
return False, "Difficulty too low"
# Token difficulty must be at least the required difficulty
if token_diff < required_difficulty:
return False, f"Difficulty too low: {token_diff} < {required_difficulty}"
# Verify solution
try:
@@ -806,6 +824,153 @@ class ChallengeView(MethodView):
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):
"""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("/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
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"])

View File

@@ -63,6 +63,8 @@ class Config:
POW_CHALLENGE_TTL = int(os.environ.get("FLASKPASTE_POW_TTL", "300")) # 5 minutes
# Secret key for signing challenges (auto-generated if not set)
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
ANTIFLOOD_ENABLED = os.environ.get("FLASKPASTE_ANTIFLOOD", "1").lower() in (

View File

@@ -31,6 +31,7 @@ try:
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives.serialization import pkcs12
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
HAS_CRYPTO = True
@@ -189,6 +190,49 @@ def calculate_fingerprint(certificate: Any) -> str:
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:
"""Standalone PKI manager for CA and certificate operations.