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

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ import os
from pathlib import Path
# Application version
VERSION = "1.1.0"
VERSION = "1.2.0"
class Config:
@@ -21,6 +21,8 @@ class Config:
# Paste expiry (default 5 days)
PASTE_EXPIRY_SECONDS = int(os.environ.get("FLASKPASTE_EXPIRY", 5 * 24 * 60 * 60))
# Maximum custom expiry (default 30 days, 0 = use default expiry as max)
MAX_EXPIRY_SECONDS = int(os.environ.get("FLASKPASTE_MAX_EXPIRY", 30 * 24 * 60 * 60))
# Content deduplication / abuse prevention
# Throttle repeated submissions of identical content
@@ -54,6 +56,16 @@ class Config:
# URL prefix for reverse proxy deployments (e.g., "/paste" for mymx.me/paste)
URL_PREFIX = os.environ.get("FLASKPASTE_URL_PREFIX", "").rstrip("/")
# PKI Configuration
# Enable PKI endpoints for certificate authority and issuance
PKI_ENABLED = os.environ.get("FLASKPASTE_PKI_ENABLED", "0").lower() in ("1", "true", "yes")
# CA password for signing operations (REQUIRED when PKI is enabled)
PKI_CA_PASSWORD = os.environ.get("FLASKPASTE_PKI_CA_PASSWORD", "")
# Default validity period for issued certificates (days)
PKI_CERT_DAYS = int(os.environ.get("FLASKPASTE_PKI_CERT_DAYS", "365"))
# CA certificate validity period (days)
PKI_CA_DAYS = int(os.environ.get("FLASKPASTE_PKI_CA_DAYS", "3650")) # 10 years
class DevelopmentConfig(Config):
"""Development configuration."""
@@ -80,6 +92,12 @@ class TestingConfig(Config):
# Disable PoW for most tests (easier testing)
POW_DIFFICULTY = 0
# PKI testing configuration
PKI_ENABLED = True
PKI_CA_PASSWORD = "test-ca-password"
PKI_CERT_DAYS = 30
PKI_CA_DAYS = 365
config = {
"development": DevelopmentConfig,

View File

@@ -1,5 +1,7 @@
"""Database connection and schema management."""
import hashlib
import secrets
import sqlite3
import time
from pathlib import Path
@@ -13,7 +15,10 @@ CREATE TABLE IF NOT EXISTS pastes (
mime_type TEXT NOT NULL DEFAULT 'text/plain',
owner TEXT,
created_at INTEGER NOT NULL,
last_accessed INTEGER NOT NULL
last_accessed INTEGER NOT NULL,
burn_after_read INTEGER NOT NULL DEFAULT 0,
expires_at INTEGER,
password_hash TEXT
);
CREATE INDEX IF NOT EXISTS idx_pastes_created_at ON pastes(created_at);
@@ -29,8 +34,86 @@ CREATE TABLE IF NOT EXISTS content_hashes (
);
CREATE INDEX IF NOT EXISTS idx_content_hashes_last_seen ON content_hashes(last_seen);
-- PKI: Certificate Authority storage
CREATE TABLE IF NOT EXISTS certificate_authority (
id TEXT PRIMARY KEY DEFAULT 'default',
common_name TEXT NOT NULL,
certificate_pem TEXT NOT NULL,
private_key_encrypted BLOB NOT NULL,
key_salt BLOB NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
key_algorithm TEXT NOT NULL,
owner TEXT
);
-- PKI: Issued client certificates
CREATE TABLE IF NOT EXISTS issued_certificates (
serial TEXT PRIMARY KEY,
ca_id TEXT NOT NULL DEFAULT 'default',
common_name TEXT NOT NULL,
fingerprint_sha1 TEXT NOT NULL UNIQUE,
certificate_pem TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
issued_to TEXT,
status TEXT NOT NULL DEFAULT 'valid',
revoked_at INTEGER,
FOREIGN KEY(ca_id) REFERENCES certificate_authority(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_certs_fingerprint ON issued_certificates(fingerprint_sha1);
CREATE INDEX IF NOT EXISTS idx_certs_status ON issued_certificates(status);
CREATE INDEX IF NOT EXISTS idx_certs_ca_id ON issued_certificates(ca_id);
"""
# Password hashing constants
_HASH_ITERATIONS = 600000 # OWASP 2023 recommendation for PBKDF2-SHA256
_SALT_LENGTH = 32
def hash_password(password: str) -> str:
"""Hash password using PBKDF2-HMAC-SHA256.
Returns format: $pbkdf2-sha256$iterations$salt$hash
All values are hex-encoded.
"""
if not password:
return None
salt = secrets.token_bytes(_SALT_LENGTH)
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _HASH_ITERATIONS)
return f"$pbkdf2-sha256${_HASH_ITERATIONS}${salt.hex()}${dk.hex()}"
def verify_password(password: str, password_hash: str) -> bool:
"""Verify password against stored hash.
Uses constant-time comparison to prevent timing attacks.
"""
if not password or not password_hash:
return False
try:
# Parse hash format: $pbkdf2-sha256$iterations$salt$hash
parts = password_hash.split("$")
if len(parts) != 5 or parts[1] != "pbkdf2-sha256":
return False
iterations = int(parts[2])
salt = bytes.fromhex(parts[3])
stored_hash = bytes.fromhex(parts[4])
# Compute hash of provided password
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
# Constant-time comparison
return secrets.compare_digest(dk, stored_hash)
except (ValueError, IndexError):
return False
# Hold reference for in-memory shared cache databases
_memory_db_holder = None
@@ -98,15 +181,27 @@ def init_db() -> None:
def cleanup_expired_pastes() -> int:
"""Delete pastes that haven't been accessed within expiry period.
"""Delete pastes that have expired.
Pastes expire based on:
- Custom expires_at timestamp if set
- Default expiry from last_accessed if expires_at is NULL
Returns number of deleted pastes.
"""
expiry_seconds = current_app.config["PASTE_EXPIRY_SECONDS"]
cutoff = int(time.time()) - expiry_seconds
now = int(time.time())
default_cutoff = now - expiry_seconds
db = get_db()
cursor = db.execute("DELETE FROM pastes WHERE last_accessed < ?", (cutoff,))
# Delete pastes with custom expiry that have passed,
# OR pastes without custom expiry that exceed default window
cursor = db.execute(
"""DELETE FROM pastes WHERE
(expires_at IS NOT NULL AND expires_at < ?)
OR (expires_at IS NULL AND last_accessed < ?)""",
(now, default_cutoff),
)
db.commit()
return cursor.rowcount
@@ -146,15 +241,14 @@ def check_content_hash(content_hash: str) -> tuple[bool, int]:
# Check existing hash record
row = db.execute(
"SELECT count, last_seen FROM content_hashes WHERE hash = ?",
(content_hash,)
"SELECT count, last_seen FROM content_hashes WHERE hash = ?", (content_hash,)
).fetchone()
if row is None:
# First time seeing this content
db.execute(
"INSERT INTO content_hashes (hash, first_seen, last_seen, count) VALUES (?, ?, ?, 1)",
(content_hash, now, now)
(content_hash, now, now),
)
db.commit()
return True, 1
@@ -163,7 +257,7 @@ def check_content_hash(content_hash: str) -> tuple[bool, int]:
# Outside window, reset counter
db.execute(
"UPDATE content_hashes SET first_seen = ?, last_seen = ?, count = 1 WHERE hash = ?",
(now, now, content_hash)
(now, now, content_hash),
)
db.commit()
return True, 1
@@ -178,7 +272,7 @@ def check_content_hash(content_hash: str) -> tuple[bool, int]:
# Update counter
db.execute(
"UPDATE content_hashes SET last_seen = ?, count = ? WHERE hash = ?",
(now, current_count, content_hash)
(now, current_count, content_hash),
)
db.commit()

1019
app/pki.py Normal file

File diff suppressed because it is too large Load Diff