diff --git a/src/easyrsa/cli.py b/src/easyrsa/cli.py index 58be99dee..2960d7361 100644 --- a/src/easyrsa/cli.py +++ b/src/easyrsa/cli.py @@ -651,8 +651,9 @@ def main(argv: Optional[list] = None) -> int: return 130 except Exception as e: print(f"\nUnexpected error: {e}", file=sys.stderr) - import traceback - traceback.print_exc() + if config.verbose: + import traceback + traceback.print_exc() return 1 diff --git a/src/easyrsa/commands/certs.py b/src/easyrsa/commands/certs.py index 7fefdefd0..cbc815f7e 100644 --- a/src/easyrsa/commands/certs.py +++ b/src/easyrsa/commands/certs.py @@ -5,6 +5,7 @@ import ipaddress import re import shutil +import sys from pathlib import Path from typing import Optional @@ -28,7 +29,7 @@ serialize_private_key, sign_csr, ) -from ..errors import EasyRSAUserError +from ..errors import EasyRSAUserError, validate_name from ..index import ( IndexRecord, append_record, @@ -71,6 +72,7 @@ def gen_req( name: file_name_base """ + validate_name(name) key_out = config.pki_dir / "private" / f"{name}.key" req_out = config.pki_dir / "reqs" / f"{name}.req" @@ -166,6 +168,7 @@ def sign_req( cert_type: 'server', 'client', 'serverClient', 'ca', etc. name: file_name_base """ + validate_name(name) from ..pki import verify_ca verify_ca(config.pki_dir) @@ -298,9 +301,12 @@ def sign_req( if not config.no_inline and cert_type != "ca": from ..inline import build_inline try: - build_inline(config, name, "std") - except Exception: - pass + build_inline(config, name) + except Exception as e: + if config.verbose: + import traceback + traceback.print_exc() + print(f"\nWarning: Failed to create inline file: {e}", file=sys.stderr) print(f"\nNotice: Certificate created at:\n* {crt_out}") @@ -361,6 +367,7 @@ def build_full( nopass: bool = False, ) -> None: """Generate key+CSR and immediately sign it (build-*-full).""" + validate_name(name) req_out = config.pki_dir / "reqs" / f"{name}.req" key_out = config.pki_dir / "private" / f"{name}.key" crt_out = config.pki_dir / "issued" / f"{name}.crt" @@ -386,6 +393,7 @@ def build_full( def renew(config: EasyRSAConfig, session: Session, name: str) -> None: """Renew a certificate keeping the same key and request.""" + validate_name(name) from ..pki import verify_ca verify_ca(config.pki_dir) diff --git a/src/easyrsa/commands/export.py b/src/easyrsa/commands/export.py index 53bd7472e..86785681e 100644 --- a/src/easyrsa/commands/export.py +++ b/src/easyrsa/commands/export.py @@ -13,7 +13,7 @@ load_private_key, serialize_private_key, ) -from ..errors import EasyRSAUserError +from ..errors import EasyRSAUserError, validate_name from ..passphrase import load_key_password, parse_passin, parse_passout, prompt_passphrase from ..session import Session @@ -29,6 +29,7 @@ def export_p12( legacy: bool = False, ) -> None: """Export a PKCS#12 file.""" + validate_name(name) crt_in = config.pki_dir / "issued" / f"{name}.crt" key_in = config.pki_dir / "private" / f"{name}.key" ca_crt = config.pki_dir / "ca.crt" @@ -90,6 +91,7 @@ def export_p12( pkcs_out.parent.mkdir(parents=True, exist_ok=True) pkcs_out.write_bytes(p12_bytes) + pkcs_out.chmod(0o600) # Also create inline p12 file (base64) _create_p12_inline(config, name, pkcs_out, cert) @@ -117,6 +119,7 @@ def _create_p12_inline(config: EasyRSAConfig, name: str, p12_path: Path, cert) - f"\n{b64}\n\n" ) inline_out.write_text(content, encoding="utf-8") + inline_out.chmod(0o600) def export_p7( @@ -125,7 +128,8 @@ def export_p7( name: str, noca: bool = False, ) -> None: - """Export a PKCS#7 (cert chain) file using cryptography or openssl fallback.""" + """Export a PKCS#7 (cert chain) file using openssl subprocess.""" + validate_name(name) crt_in = config.pki_dir / "issued" / f"{name}.crt" ca_crt = config.pki_dir / "ca.crt" pkcs_out = config.pki_dir / "issued" / f"{name}.p7b" @@ -133,26 +137,14 @@ def export_p7( if not crt_in.exists(): raise EasyRSAUserError(f"Missing User Certificate:\n* {crt_in}") - # Use cryptography library PKCS7 builder - try: - from cryptography.hazmat.primitives.serialization import pkcs7 - from cryptography.hazmat.primitives.serialization import Encoding - - cert = load_cert(crt_in.read_bytes()) - builder = pkcs7.PKCS7SignatureBuilder() - - # PKCS7 cert-only (no signatures) — use openssl as fallback if not available - # The cryptography library PKCS7 signing is complex; use openssl for cert chain - raise ImportError("Use openssl fallback for PKCS7 cert-only") - except (ImportError, AttributeError): - # Fallback to openssl subprocess for PKCS#7 - import subprocess - cmd = ["openssl", "crl2pkcs7", "-nocrl", "-certfile", str(crt_in), "-out", str(pkcs_out)] - if not noca and ca_crt.exists(): - cmd += ["-certfile", str(ca_crt)] - result = subprocess.run(cmd, capture_output=True) - if result.returncode != 0: - raise EasyRSAUserError(f"Failed to export PKCS#7:\n{result.stderr.decode()}") + # Use openssl subprocess for PKCS#7 cert-only bundle + import subprocess + cmd = ["openssl", "crl2pkcs7", "-nocrl", "-certfile", str(crt_in), "-out", str(pkcs_out)] + if not noca and ca_crt.exists(): + cmd += ["-certfile", str(ca_crt)] + result = subprocess.run(cmd, capture_output=True) + if result.returncode != 0: + raise EasyRSAUserError(f"Failed to export PKCS#7:\n{result.stderr.decode()}") print(f"\nNotice: Successful export of p7 file. Your exported file is at:\n* {pkcs_out}") @@ -164,6 +156,7 @@ def export_p8( nopass: bool = False, ) -> None: """Export private key as PKCS#8.""" + validate_name(name) key_in = config.pki_dir / "private" / f"{name}.key" pkcs_out = config.pki_dir / "private" / f"{name}.p8" @@ -188,6 +181,7 @@ def export_p8( p8_bytes = key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, encryption) pkcs_out.write_bytes(p8_bytes) + pkcs_out.chmod(0o600) print(f"\nNotice: Successful export of p8 file. Your exported file is at:\n* {pkcs_out}") @@ -198,6 +192,7 @@ def export_p1( nopass: bool = False, ) -> None: """Export RSA private key in PKCS#1 (TraditionalOpenSSL) format.""" + validate_name(name) key_in = config.pki_dir / "private" / f"{name}.key" pkcs_out = config.pki_dir / "private" / f"{name}.p1" @@ -222,4 +217,5 @@ def export_p1( p1_bytes = key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, encryption) pkcs_out.write_bytes(p1_bytes) + pkcs_out.chmod(0o600) print(f"\nNotice: Successful export of p1 file. Your exported file is at:\n* {pkcs_out}") diff --git a/src/easyrsa/crypto.py b/src/easyrsa/crypto.py index bb12d0f2d..210ac310e 100644 --- a/src/easyrsa/crypto.py +++ b/src/easyrsa/crypto.py @@ -46,8 +46,6 @@ def _get_hash(digest: str, key=None): "sha384": hashes.SHA384(), "sha512": hashes.SHA512(), "sha224": hashes.SHA224(), - "sha1": hashes.SHA1(), - "md5": hashes.MD5(), } h = digest_map.get(digest.lower()) if h is None: @@ -66,8 +64,6 @@ def _get_ec_curve(curve_name: str): "secp256r1": ec.SECP256R1(), "prime256v1": ec.SECP256R1(), "secp521r1": ec.SECP521R1(), - "secp224r1": ec.SECP224R1(), - "secp192r1": ec.SECP192R1(), "brainpoolP256r1": ec.BrainpoolP256R1(), "brainpoolP384r1": ec.BrainpoolP384R1(), "brainpoolP512r1": ec.BrainpoolP512R1(), diff --git a/src/easyrsa/errors.py b/src/easyrsa/errors.py index d7e4e11ee..068ab60ff 100644 --- a/src/easyrsa/errors.py +++ b/src/easyrsa/errors.py @@ -1,5 +1,9 @@ """Easy-RSA exception classes.""" +import re + +_SAFE_NAME_RE = re.compile(r'^[A-Za-z0-9_.-]+$') + class EasyRSAError(Exception): """Base exception for Easy-RSA errors.""" @@ -20,3 +24,13 @@ class EasyRSALockError(EasyRSAError): def __init__(self, message: str): super().__init__(message, exit_code=17) + + +def validate_name(name: str) -> None: + """Raise EasyRSAUserError if name is unsafe as a filename.""" + if not name or name in ('.', '..') or '/' in name or '\\' in name: + raise EasyRSAUserError(f"Invalid name: '{name}'") + if not _SAFE_NAME_RE.match(name): + raise EasyRSAUserError( + f"Invalid name '{name}': only alphanumeric, hyphen, underscore, and dot are allowed" + ) diff --git a/src/easyrsa/inline.py b/src/easyrsa/inline.py index 1d7c46c90..95bbdd02d 100644 --- a/src/easyrsa/inline.py +++ b/src/easyrsa/inline.py @@ -63,6 +63,7 @@ def build_inline( pri_dir.mkdir(parents=True, exist_ok=True) pri_out = pri_dir / f"{name}.inline" pri_out.write_text(pri_content, encoding="utf-8") + pri_out.chmod(0o600) print(f"\nNotice: Inline file created at:\n* {pub_out}") print(f"\nNotice: Private inline file created at:\n* {pri_out}") diff --git a/src/easyrsa/passphrase.py b/src/easyrsa/passphrase.py index b0e9d30d8..a8d5c8a7a 100644 --- a/src/easyrsa/passphrase.py +++ b/src/easyrsa/passphrase.py @@ -4,6 +4,7 @@ import getpass import os +import sys from pathlib import Path from typing import Optional @@ -13,19 +14,19 @@ def prompt_passphrase(prompt: str, confirm: bool = False, allow_empty: bool = False) -> bytes: """Prompt for a passphrase interactively. - Enforces minimum 4-character length (unless allow_empty=True). + Enforces minimum 8-character length (unless allow_empty=True). If confirm=True, prompts twice and ensures they match. Returns the passphrase as bytes (empty bytes b"" if allowed and no input given). """ while True: pw = getpass.getpass(prompt) - if not allow_empty and len(pw) < 4: - print("Passphrase must be at least 4 characters!") + if not allow_empty and len(pw) < 8: + print("Passphrase must be at least 8 characters!") continue if allow_empty and pw == "": return b"" - if len(pw) < 4: - print("Passphrase must be at least 4 characters!") + if len(pw) < 8: + print("Passphrase must be at least 8 characters!") continue if confirm: pw2 = getpass.getpass("Confirm passphrase: ") @@ -46,6 +47,11 @@ def parse_passin(passin_str: str) -> Optional[bytes]: if not passin_str: return None if passin_str.startswith("pass:"): + print( + "Warning: Using pass: exposes the passphrase in process listings. " + "Prefer file: or env: instead.", + file=sys.stderr, + ) return passin_str[5:].encode("utf-8") if passin_str.startswith("file:"): fp = Path(passin_str[5:]) diff --git a/src/easyrsa/pki.py b/src/easyrsa/pki.py index 2966573dd..7659d6707 100644 --- a/src/easyrsa/pki.py +++ b/src/easyrsa/pki.py @@ -38,9 +38,13 @@ def init_pki(pki_dir: Path, algo: str = "rsa", curve: str = "", batch: bool = Fa raise EasyRSAUserError("init-pki aborted by user.") shutil.rmtree(pki_dir) - # Create required directories + # Create required directories with appropriate permissions for subdir in _PKI_DIRS: - (pki_dir / subdir).mkdir(parents=True, exist_ok=True) + d = pki_dir / subdir + if subdir == "private": + d.mkdir(parents=True, exist_ok=True, mode=0o700) + else: + d.mkdir(parents=True, exist_ok=True, mode=0o755) # Write vars.example from package data _write_vars_example(pki_dir) @@ -161,7 +165,7 @@ def create_ca_dirs(pki_dir: Path) -> None: """Create the CA-specific subdirectories.""" for subdir in ["certs_by_serial", "revoked/certs_by_serial", "revoked/private_by_serial", "revoked/reqs_by_serial"]: - (pki_dir / subdir).mkdir(parents=True, exist_ok=True) + (pki_dir / subdir).mkdir(parents=True, exist_ok=True, mode=0o755) def init_ca_files(pki_dir: Path) -> None: @@ -169,9 +173,11 @@ def init_ca_files(pki_dir: Path) -> None: index = pki_dir / "index.txt" if not index.exists(): index.write_text("", encoding="utf-8") + index.chmod(0o600) attr = pki_dir / "index.txt.attr" attr.write_text("unique_subject = no\n", encoding="utf-8") + attr.chmod(0o600) serial = pki_dir / "serial" if not serial.exists():