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:
491
fpaste
491
fpaste
@@ -6,14 +6,20 @@ import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# Optional encryption support
|
||||
# Optional cryptography support (for encryption and cert generation)
|
||||
try:
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
HAS_CRYPTO = True
|
||||
except ImportError:
|
||||
@@ -25,6 +31,9 @@ def get_config():
|
||||
config = {
|
||||
"server": os.environ.get("FLASKPASTE_SERVER", "http://localhost:5000"),
|
||||
"cert_sha1": os.environ.get("FLASKPASTE_CERT_SHA1", ""),
|
||||
"client_cert": os.environ.get("FLASKPASTE_CLIENT_CERT", ""),
|
||||
"client_key": os.environ.get("FLASKPASTE_CLIENT_KEY", ""),
|
||||
"ca_cert": os.environ.get("FLASKPASTE_CA_CERT", ""),
|
||||
}
|
||||
|
||||
# Try config file
|
||||
@@ -40,17 +49,51 @@ def get_config():
|
||||
config["server"] = value
|
||||
elif key == "cert_sha1":
|
||||
config["cert_sha1"] = value
|
||||
elif key == "client_cert":
|
||||
config["client_cert"] = value
|
||||
elif key == "client_key":
|
||||
config["client_key"] = value
|
||||
elif key == "ca_cert":
|
||||
config["ca_cert"] = value
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def request(url, method="GET", data=None, headers=None):
|
||||
def create_ssl_context(config):
|
||||
"""Create SSL context for mTLS if certificates are configured."""
|
||||
client_cert = config.get("client_cert", "")
|
||||
client_key = config.get("client_key", "")
|
||||
ca_cert = config.get("ca_cert", "")
|
||||
|
||||
if not client_cert:
|
||||
return None
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
|
||||
# Load CA certificate if specified
|
||||
if ca_cert:
|
||||
ctx.load_verify_locations(ca_cert)
|
||||
|
||||
# Load client certificate and key
|
||||
try:
|
||||
ctx.load_cert_chain(certfile=client_cert, keyfile=client_key or None)
|
||||
except ssl.SSLError as e:
|
||||
die(f"failed to load client certificate: {e}")
|
||||
except FileNotFoundError as e:
|
||||
die(f"certificate file not found: {e}")
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
def request(url, method="GET", data=None, headers=None, ssl_context=None):
|
||||
"""Make HTTP request and return response."""
|
||||
headers = headers or {}
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
# User-configured server URL, audit is expected
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method) # noqa: S310
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
# User-configured server URL, audit is expected
|
||||
with urllib.request.urlopen(req, timeout=30, context=ssl_context) as resp: # noqa: S310
|
||||
return resp.status, resp.read(), dict(resp.headers)
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read(), dict(e.headers)
|
||||
@@ -120,11 +163,11 @@ def solve_pow(nonce, difficulty):
|
||||
|
||||
# Count leading zero bits
|
||||
zero_bits = 0
|
||||
for byte in hash_bytes[:target_bytes + 1]:
|
||||
for byte in hash_bytes[: target_bytes + 1]:
|
||||
if byte == 0:
|
||||
zero_bits += 8
|
||||
else:
|
||||
zero_bits += (8 - byte.bit_length())
|
||||
zero_bits += 8 - byte.bit_length()
|
||||
break
|
||||
|
||||
if zero_bits >= difficulty:
|
||||
@@ -141,7 +184,7 @@ def solve_pow(nonce, difficulty):
|
||||
def get_challenge(config):
|
||||
"""Fetch PoW challenge from server."""
|
||||
url = config["server"].rstrip("/") + "/challenge"
|
||||
status, body, _ = request(url)
|
||||
status, body, _ = request(url, ssl_context=config.get("ssl_context"))
|
||||
|
||||
if status != 200:
|
||||
return None
|
||||
@@ -186,6 +229,18 @@ def cmd_create(args, config):
|
||||
if config["cert_sha1"]:
|
||||
headers["X-SSL-Client-SHA1"] = config["cert_sha1"]
|
||||
|
||||
# Add burn-after-read header
|
||||
if args.burn:
|
||||
headers["X-Burn-After-Read"] = "true"
|
||||
|
||||
# Add custom expiry header
|
||||
if args.expiry:
|
||||
headers["X-Expiry"] = str(args.expiry)
|
||||
|
||||
# Add password header
|
||||
if args.password:
|
||||
headers["X-Paste-Password"] = args.password
|
||||
|
||||
# Get and solve PoW challenge if required
|
||||
challenge = get_challenge(config)
|
||||
if challenge:
|
||||
@@ -198,7 +253,9 @@ def cmd_create(args, config):
|
||||
headers["X-PoW-Solution"] = str(solution)
|
||||
|
||||
url = config["server"].rstrip("/") + "/"
|
||||
status, body, _ = request(url, method="POST", data=content, headers=headers)
|
||||
status, body, _ = request(
|
||||
url, method="POST", data=content, headers=headers, ssl_context=config.get("ssl_context")
|
||||
)
|
||||
|
||||
if status == 201:
|
||||
data = json.loads(body)
|
||||
@@ -236,9 +293,14 @@ def cmd_get(args, config):
|
||||
paste_id = url_input.split("/")[-1] # Handle full URLs
|
||||
base = config["server"].rstrip("/")
|
||||
|
||||
# Build headers for password-protected pastes
|
||||
headers = {}
|
||||
if args.password:
|
||||
headers["X-Paste-Password"] = args.password
|
||||
|
||||
if args.meta:
|
||||
url = f"{base}/{paste_id}"
|
||||
status, body, _ = request(url)
|
||||
status, body, _ = request(url, headers=headers, ssl_context=config.get("ssl_context"))
|
||||
if status == 200:
|
||||
data = json.loads(body)
|
||||
print(f"id: {data['id']}")
|
||||
@@ -246,12 +308,19 @@ def cmd_get(args, config):
|
||||
print(f"size: {data['size']}")
|
||||
print(f"created_at: {data['created_at']}")
|
||||
if encryption_key:
|
||||
print(f"encrypted: yes (key in URL)")
|
||||
print("encrypted: yes (key in URL)")
|
||||
if data.get("password_protected"):
|
||||
print("protected: yes (password required)")
|
||||
elif status == 401:
|
||||
die("password required (-p)")
|
||||
elif status == 403:
|
||||
die("invalid password")
|
||||
else:
|
||||
die(f"not found: {paste_id}")
|
||||
else:
|
||||
url = f"{base}/{paste_id}/raw"
|
||||
status, body, headers = request(url)
|
||||
ssl_ctx = config.get("ssl_context")
|
||||
status, body, _ = request(url, headers=headers, ssl_context=ssl_ctx)
|
||||
if status == 200:
|
||||
# Decrypt if encryption key was provided
|
||||
if encryption_key:
|
||||
@@ -266,6 +335,10 @@ def cmd_get(args, config):
|
||||
# Add newline if content doesn't end with one and stdout is tty
|
||||
if sys.stdout.isatty() and body and not body.endswith(b"\n"):
|
||||
sys.stdout.buffer.write(b"\n")
|
||||
elif status == 401:
|
||||
die("password required (-p)")
|
||||
elif status == 403:
|
||||
die("invalid password")
|
||||
else:
|
||||
die(f"not found: {paste_id}")
|
||||
|
||||
@@ -280,7 +353,9 @@ def cmd_delete(args, config):
|
||||
url = f"{base}/{paste_id}"
|
||||
|
||||
headers = {"X-SSL-Client-SHA1": config["cert_sha1"]}
|
||||
status, body, _ = request(url, method="DELETE", headers=headers)
|
||||
status, _, _ = request(
|
||||
url, method="DELETE", headers=headers, ssl_context=config.get("ssl_context")
|
||||
)
|
||||
|
||||
if status == 200:
|
||||
print(f"deleted: {paste_id}")
|
||||
@@ -297,7 +372,7 @@ def cmd_delete(args, config):
|
||||
def cmd_info(args, config):
|
||||
"""Show server info."""
|
||||
url = config["server"].rstrip("/") + "/"
|
||||
status, body, _ = request(url)
|
||||
status, body, _ = request(url, ssl_context=config.get("ssl_context"))
|
||||
|
||||
if status == 200:
|
||||
data = json.loads(body)
|
||||
@@ -308,21 +383,337 @@ def cmd_info(args, config):
|
||||
die("failed to connect to server")
|
||||
|
||||
|
||||
def cmd_pki_status(args, config):
|
||||
"""Show PKI status and CA information."""
|
||||
url = config["server"].rstrip("/") + "/pki"
|
||||
status, body, _ = request(url, ssl_context=config.get("ssl_context"))
|
||||
|
||||
if status == 404:
|
||||
die("PKI not enabled on this server")
|
||||
elif status != 200:
|
||||
die(f"failed to get PKI status ({status})")
|
||||
|
||||
data = json.loads(body)
|
||||
|
||||
print(f"pki enabled: {data.get('enabled', False)}")
|
||||
print(f"ca exists: {data.get('ca_exists', False)}")
|
||||
|
||||
if data.get("ca_exists"):
|
||||
print(f"common name: {data.get('common_name', 'unknown')}")
|
||||
print(f"fingerprint: {data.get('fingerprint_sha1', 'unknown')}")
|
||||
if data.get("created_at"):
|
||||
print(f"created: {data.get('created_at')}")
|
||||
if data.get("expires_at"):
|
||||
print(f"expires: {data.get('expires_at')}")
|
||||
print(f"download: {config['server'].rstrip('/')}{data.get('download', '/pki/ca.crt')}")
|
||||
elif data.get("hint"):
|
||||
print(f"hint: {data.get('hint')}")
|
||||
|
||||
|
||||
def cmd_pki_issue(args, config):
|
||||
"""Request a new client certificate from the server CA."""
|
||||
url = config["server"].rstrip("/") + "/pki/issue"
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if config["cert_sha1"]:
|
||||
headers["X-SSL-Client-SHA1"] = config["cert_sha1"]
|
||||
|
||||
payload = {"common_name": args.name}
|
||||
data = json.dumps(payload).encode()
|
||||
|
||||
status, body, _ = request(
|
||||
url, method="POST", data=data, headers=headers, ssl_context=config.get("ssl_context")
|
||||
)
|
||||
|
||||
if status == 404:
|
||||
# Could be PKI disabled or no CA
|
||||
try:
|
||||
err = json.loads(body).get("error", "PKI not available")
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
err = "PKI not available"
|
||||
die(err)
|
||||
elif status == 400:
|
||||
try:
|
||||
err = json.loads(body).get("error", "bad request")
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
err = "bad request"
|
||||
die(err)
|
||||
elif status != 201:
|
||||
die(f"certificate issuance failed ({status})")
|
||||
|
||||
result = json.loads(body)
|
||||
|
||||
# Determine output directory
|
||||
out_dir = Path(args.output) if args.output else Path.home() / ".config" / "fpaste"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# File paths
|
||||
key_file = out_dir / "client.key"
|
||||
cert_file = out_dir / "client.crt"
|
||||
|
||||
# Check for existing files
|
||||
if not args.force:
|
||||
if key_file.exists():
|
||||
die(f"key file exists: {key_file} (use --force)")
|
||||
if cert_file.exists():
|
||||
die(f"cert file exists: {cert_file} (use --force)")
|
||||
|
||||
# Write files
|
||||
key_file.write_text(result["private_key_pem"])
|
||||
key_file.chmod(0o600)
|
||||
cert_file.write_text(result["certificate_pem"])
|
||||
|
||||
fingerprint = result.get("fingerprint_sha1", "unknown")
|
||||
|
||||
print(f"key: {key_file}", file=sys.stderr)
|
||||
print(f"certificate: {cert_file}", file=sys.stderr)
|
||||
print(f"fingerprint: {fingerprint}", file=sys.stderr)
|
||||
print(f"serial: {result.get('serial', 'unknown')}", file=sys.stderr)
|
||||
print(f"common name: {result.get('common_name', args.name)}", file=sys.stderr)
|
||||
|
||||
# Update config file if requested
|
||||
if args.configure:
|
||||
config_file = Path.home() / ".config" / "fpaste" / "config"
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Read existing config
|
||||
existing = {}
|
||||
if config_file.exists():
|
||||
for line in config_file.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
existing[k.strip().lower()] = v.strip()
|
||||
|
||||
# Update values
|
||||
existing["client_cert"] = str(cert_file)
|
||||
existing["client_key"] = str(key_file)
|
||||
existing["cert_sha1"] = fingerprint
|
||||
|
||||
# Write config
|
||||
lines = [f"{k} = {v}" for k, v in sorted(existing.items())]
|
||||
config_file.write_text("\n".join(lines) + "\n")
|
||||
print(f"config: {config_file} (updated)", file=sys.stderr)
|
||||
|
||||
# Output fingerprint to stdout for easy capture
|
||||
print(fingerprint)
|
||||
|
||||
|
||||
def cmd_pki_download(args, config):
|
||||
"""Download the CA certificate from the server."""
|
||||
url = config["server"].rstrip("/") + "/pki/ca.crt"
|
||||
status, body, _ = request(url, ssl_context=config.get("ssl_context"))
|
||||
|
||||
if status == 404:
|
||||
die("CA certificate not available (PKI disabled or CA not generated)")
|
||||
elif status != 200:
|
||||
die(f"failed to download CA certificate ({status})")
|
||||
|
||||
# Determine output
|
||||
if args.output:
|
||||
out_path = Path(args.output)
|
||||
out_path.write_bytes(body)
|
||||
print(f"saved: {out_path}", file=sys.stderr)
|
||||
|
||||
# Calculate and show fingerprint if cryptography available
|
||||
if HAS_CRYPTO:
|
||||
cert = x509.load_pem_x509_certificate(body)
|
||||
# SHA1 is standard for X.509 fingerprints
|
||||
fp = hashlib.sha1(cert.public_bytes(serialization.Encoding.DER)).hexdigest() # noqa: S324
|
||||
print(f"fingerprint: {fp}", file=sys.stderr)
|
||||
|
||||
# Update config if requested
|
||||
if args.configure:
|
||||
config_file = Path.home() / ".config" / "fpaste" / "config"
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
existing = {}
|
||||
if config_file.exists():
|
||||
for line in config_file.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
existing[k.strip().lower()] = v.strip()
|
||||
|
||||
existing["ca_cert"] = str(out_path)
|
||||
|
||||
lines = [f"{k} = {v}" for k, v in sorted(existing.items())]
|
||||
config_file.write_text("\n".join(lines) + "\n")
|
||||
print(f"config: {config_file} (updated)", file=sys.stderr)
|
||||
else:
|
||||
# Output to stdout
|
||||
sys.stdout.buffer.write(body)
|
||||
|
||||
|
||||
def cmd_cert(args, config):
|
||||
"""Generate a self-signed client certificate for mTLS authentication."""
|
||||
if not HAS_CRYPTO:
|
||||
die("certificate generation requires 'cryptography' package: pip install cryptography")
|
||||
|
||||
# Determine output directory
|
||||
out_dir = Path(args.output) if args.output else Path.home() / ".config" / "fpaste"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# File paths
|
||||
key_file = out_dir / "client.key"
|
||||
cert_file = out_dir / "client.crt"
|
||||
|
||||
# Check for existing files
|
||||
if not args.force:
|
||||
if key_file.exists():
|
||||
die(f"key file exists: {key_file} (use --force)")
|
||||
if cert_file.exists():
|
||||
die(f"cert file exists: {cert_file} (use --force)")
|
||||
|
||||
# Generate private key
|
||||
if args.algorithm == "rsa":
|
||||
key_size = args.bits or 4096
|
||||
print(f"generating {key_size}-bit RSA key...", file=sys.stderr)
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=key_size,
|
||||
)
|
||||
elif args.algorithm == "ec":
|
||||
curve_name = args.curve or "secp384r1"
|
||||
curves = {
|
||||
"secp256r1": ec.SECP256R1(),
|
||||
"secp384r1": ec.SECP384R1(),
|
||||
"secp521r1": ec.SECP521R1(),
|
||||
}
|
||||
if curve_name not in curves:
|
||||
die(f"unsupported curve: {curve_name} (use: secp256r1, secp384r1, secp521r1)")
|
||||
print(f"generating EC key ({curve_name})...", file=sys.stderr)
|
||||
private_key = ec.generate_private_key(curves[curve_name])
|
||||
else:
|
||||
die(f"unsupported algorithm: {args.algorithm}")
|
||||
|
||||
# Certificate subject
|
||||
cn = args.name or os.environ.get("USER", "fpaste-client")
|
||||
subject = issuer = x509.Name(
|
||||
[
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, cn),
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "FlaskPaste Client"),
|
||||
]
|
||||
)
|
||||
|
||||
# Validity period
|
||||
days = args.days or 365
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Build certificate
|
||||
cert_builder = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(issuer)
|
||||
.public_key(private_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now)
|
||||
.not_valid_after(now + timedelta(days=days))
|
||||
.add_extension(
|
||||
x509.BasicConstraints(ca=False, path_length=None),
|
||||
critical=True,
|
||||
)
|
||||
.add_extension(
|
||||
x509.KeyUsage(
|
||||
digital_signature=True,
|
||||
key_encipherment=True,
|
||||
content_commitment=False,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
key_cert_sign=False,
|
||||
crl_sign=False,
|
||||
encipher_only=False,
|
||||
decipher_only=False,
|
||||
),
|
||||
critical=True,
|
||||
)
|
||||
.add_extension(
|
||||
x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH]),
|
||||
critical=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Sign certificate
|
||||
print("signing certificate...", file=sys.stderr)
|
||||
certificate = cert_builder.sign(private_key, hashes.SHA256())
|
||||
|
||||
# Calculate SHA1 fingerprint (standard for X.509)
|
||||
cert_der = certificate.public_bytes(serialization.Encoding.DER)
|
||||
fingerprint = hashlib.sha1(cert_der).hexdigest() # noqa: S324
|
||||
|
||||
# Serialize private key
|
||||
if args.password_key:
|
||||
key_encryption = serialization.BestAvailableEncryption(args.password_key.encode("utf-8"))
|
||||
else:
|
||||
key_encryption = serialization.NoEncryption()
|
||||
|
||||
key_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=key_encryption,
|
||||
)
|
||||
|
||||
# Serialize certificate
|
||||
cert_pem = certificate.public_bytes(serialization.Encoding.PEM)
|
||||
|
||||
# Write files
|
||||
key_file.write_bytes(key_pem)
|
||||
key_file.chmod(0o600) # Restrict permissions
|
||||
cert_file.write_bytes(cert_pem)
|
||||
|
||||
print(f"key: {key_file}", file=sys.stderr)
|
||||
print(f"certificate: {cert_file}", file=sys.stderr)
|
||||
print(f"fingerprint: {fingerprint}", file=sys.stderr)
|
||||
print(f"valid for: {days} days", file=sys.stderr)
|
||||
print(f"common name: {cn}", file=sys.stderr)
|
||||
|
||||
# Update config file if requested
|
||||
if args.configure:
|
||||
config_file = Path.home() / ".config" / "fpaste" / "config"
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Read existing config
|
||||
existing = {}
|
||||
if config_file.exists():
|
||||
for line in config_file.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
existing[k.strip().lower()] = v.strip()
|
||||
|
||||
# Update values
|
||||
existing["client_cert"] = str(cert_file)
|
||||
existing["client_key"] = str(key_file)
|
||||
existing["cert_sha1"] = fingerprint
|
||||
|
||||
# Write config
|
||||
lines = [f"{k} = {v}" for k, v in sorted(existing.items())]
|
||||
config_file.write_text("\n".join(lines) + "\n")
|
||||
print(f"config: {config_file} (updated)", file=sys.stderr)
|
||||
|
||||
# Output fingerprint to stdout for easy capture
|
||||
print(fingerprint)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="fpaste",
|
||||
description="FlaskPaste command-line client",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-s", "--server",
|
||||
help="server URL (default: $FLASKPASTE_SERVER or http://localhost:5000)",
|
||||
"-s",
|
||||
"--server",
|
||||
help="server URL (env: FLASKPASTE_SERVER)",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", metavar="command")
|
||||
|
||||
# create
|
||||
p_create = subparsers.add_parser("create", aliases=["c", "new"], help="create paste")
|
||||
p_create.add_argument("file", nargs="?", help="file to upload (- for stdin)")
|
||||
p_create.add_argument("-e", "--encrypt", action="store_true", help="encrypt content (E2E)")
|
||||
p_create.add_argument("-e", "--encrypt", action="store_true", help="encrypt content")
|
||||
p_create.add_argument("-b", "--burn", action="store_true", help="burn after read")
|
||||
p_create.add_argument("-x", "--expiry", type=int, metavar="SEC", help="expiry in seconds")
|
||||
p_create.add_argument("-p", "--password", metavar="PASS", help="password protect")
|
||||
p_create.add_argument("-r", "--raw", action="store_true", help="output raw URL")
|
||||
p_create.add_argument("-q", "--quiet", action="store_true", help="output ID only")
|
||||
|
||||
@@ -330,6 +721,7 @@ def main():
|
||||
p_get = subparsers.add_parser("get", aliases=["g"], help="retrieve paste")
|
||||
p_get.add_argument("id", help="paste ID or URL")
|
||||
p_get.add_argument("-o", "--output", help="save to file")
|
||||
p_get.add_argument("-p", "--password", metavar="PASS", help="password for protected paste")
|
||||
p_get.add_argument("-m", "--meta", action="store_true", help="show metadata only")
|
||||
|
||||
# delete
|
||||
@@ -339,18 +731,73 @@ def main():
|
||||
# info
|
||||
subparsers.add_parser("info", aliases=["i"], help="show server info")
|
||||
|
||||
# cert
|
||||
p_cert = subparsers.add_parser("cert", help="generate client certificate")
|
||||
p_cert.add_argument("-o", "--output", metavar="DIR", help="output directory")
|
||||
p_cert.add_argument(
|
||||
"-a", "--algorithm", choices=["rsa", "ec"], default="ec", help="key algorithm (default: ec)"
|
||||
)
|
||||
p_cert.add_argument("-b", "--bits", type=int, metavar="N", help="RSA key size (default: 4096)")
|
||||
p_cert.add_argument(
|
||||
"-c", "--curve", metavar="CURVE", help="EC curve: secp256r1, secp384r1, secp521r1"
|
||||
)
|
||||
p_cert.add_argument("-d", "--days", type=int, metavar="N", help="validity period in days")
|
||||
p_cert.add_argument("-n", "--name", metavar="CN", help="common name (default: $USER)")
|
||||
p_cert.add_argument("--password-key", metavar="PASS", help="encrypt private key with password")
|
||||
p_cert.add_argument(
|
||||
"--configure", action="store_true", help="update config file with generated cert paths"
|
||||
)
|
||||
p_cert.add_argument("-f", "--force", action="store_true", help="overwrite existing files")
|
||||
|
||||
# pki (with subcommands)
|
||||
p_pki = subparsers.add_parser("pki", help="PKI operations (server-issued certificates)")
|
||||
pki_sub = p_pki.add_subparsers(dest="pki_command", metavar="subcommand")
|
||||
|
||||
# pki status
|
||||
pki_sub.add_parser("status", help="show PKI status and CA info")
|
||||
|
||||
# pki issue
|
||||
p_pki_issue = pki_sub.add_parser("issue", help="request certificate from server CA")
|
||||
p_pki_issue.add_argument(
|
||||
"-n", "--name", required=True, metavar="CN", help="common name for certificate (required)"
|
||||
)
|
||||
p_pki_issue.add_argument(
|
||||
"-o", "--output", metavar="DIR", help="output directory (default: ~/.config/fpaste)"
|
||||
)
|
||||
p_pki_issue.add_argument(
|
||||
"--configure", action="store_true", help="update config file with issued cert paths"
|
||||
)
|
||||
p_pki_issue.add_argument("-f", "--force", action="store_true", help="overwrite existing files")
|
||||
|
||||
# pki download
|
||||
p_pki_download = pki_sub.add_parser("download", aliases=["dl"], help="download CA certificate")
|
||||
p_pki_download.add_argument(
|
||||
"-o", "--output", metavar="FILE", help="save to file (default: stdout)"
|
||||
)
|
||||
p_pki_download.add_argument(
|
||||
"--configure",
|
||||
action="store_true",
|
||||
help="update config file with CA cert path (requires -o)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
config = get_config()
|
||||
|
||||
if args.server:
|
||||
config["server"] = args.server
|
||||
|
||||
# Create SSL context for mTLS if configured
|
||||
config["ssl_context"] = create_ssl_context(config)
|
||||
|
||||
if not args.command:
|
||||
# Default: create from stdin if data is piped
|
||||
if not sys.stdin.isatty():
|
||||
args.command = "create"
|
||||
args.file = None
|
||||
args.encrypt = False
|
||||
args.burn = False
|
||||
args.expiry = None
|
||||
args.password = None
|
||||
args.raw = False
|
||||
args.quiet = False
|
||||
else:
|
||||
@@ -365,6 +812,18 @@ def main():
|
||||
cmd_delete(args, config)
|
||||
elif args.command in ("info", "i"):
|
||||
cmd_info(args, config)
|
||||
elif args.command == "cert":
|
||||
cmd_cert(args, config)
|
||||
elif args.command == "pki":
|
||||
if args.pki_command == "status":
|
||||
cmd_pki_status(args, config)
|
||||
elif args.pki_command == "issue":
|
||||
cmd_pki_issue(args, config)
|
||||
elif args.pki_command in ("download", "dl"):
|
||||
cmd_pki_download(args, config)
|
||||
else:
|
||||
# Show pki help if no subcommand
|
||||
parser.parse_args(["pki", "--help"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user