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:
@@ -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"])
|
||||
|
||||
@@ -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 (
|
||||
|
||||
44
app/pki.py
44
app/pki.py
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user