Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/easyrsa/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
16 changes: 12 additions & 4 deletions src/easyrsa/commands/certs.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import ipaddress
import re
import shutil
import sys
from pathlib import Path
from typing import Optional

Expand All @@ -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,
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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}")

Expand Down Expand Up @@ -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"
Expand All @@ -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)

Expand Down
40 changes: 18 additions & 22 deletions src/easyrsa/commands/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -117,6 +119,7 @@ def _create_p12_inline(config: EasyRSAConfig, name: str, p12_path: Path, cert) -
f"<pkcs12>\n{b64}\n</pkcs12>\n"
)
inline_out.write_text(content, encoding="utf-8")
inline_out.chmod(0o600)


def export_p7(
Expand All @@ -125,34 +128,23 @@ 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"

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}")

Expand All @@ -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"

Expand All @@ -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}")


Expand All @@ -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"

Expand All @@ -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}")
4 changes: 0 additions & 4 deletions src/easyrsa/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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(),
Expand Down
14 changes: 14 additions & 0 deletions src/easyrsa/errors.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand All @@ -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"
)
1 change: 1 addition & 0 deletions src/easyrsa/inline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
16 changes: 11 additions & 5 deletions src/easyrsa/passphrase.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import getpass
import os
import sys
from pathlib import Path
from typing import Optional

Expand All @@ -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: ")
Expand All @@ -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:])
Expand Down
12 changes: 9 additions & 3 deletions src/easyrsa/pki.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -161,17 +165,19 @@ 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:
"""Initialise index.txt, index.txt.attr, and serial=01."""
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():
Expand Down