forked from claw/flaskpaste
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:
1319
app/api/routes.py
1319
app/api/routes.py
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
112
app/database.py
112
app/database.py
@@ -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
1019
app/pki.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user