From a7d83c9684ca213dbed055ba839bcc4f64667e3c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 20:20:41 +0000 Subject: [PATCH] Make sudo optional for VeraCrypt operations on Linux/macOS File-container creation does not require root, yet the *nix command builders hardcoded `sudo` as the first argument. Worse, when `sudo` itself needs a password it consumes the volume password fed via stdin, breaking headless/non-interactive `create_volume` callers. Add two constructor params (backward-compatible defaults): - `use_sudo` (default True): whether to prefix commands with `sudo`. - `sudo_non_interactive` (default False): use `sudo -n` so it never prompts, avoiding the sudo/stdin password collision. A new `_privilege_prefix()` helper emits `[]`, `["sudo"]`, or `["sudo", "-n"]`, and every *nix builder (_create_nix, _mount_nix, _dismount_nix, _custom_nix) now splats it in place of the literal "sudo". Defaults preserve today's behavior for existing callers. Bump version to 0.2.0, document the params in the README and docstrings, and add unit tests covering the prefix helper and each builder. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KmtFTMrCghkkNXAkvdXKS2 --- README.md | 22 ++ build/lib/veracrypt/__about__.py | 7 + build/lib/veracrypt/__init__.py | 3 + build/lib/veracrypt/veracrypt.py | 650 +++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/veracrypt/__about__.py | 2 +- src/veracrypt/veracrypt.py | 38 +- tests/test_veracrypt.py | 72 ++++ 8 files changed, 790 insertions(+), 6 deletions(-) create mode 100644 build/lib/veracrypt/__about__.py create mode 100644 build/lib/veracrypt/__init__.py create mode 100644 build/lib/veracrypt/veracrypt.py diff --git a/README.md b/README.md index 124b807..66e56b9 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,28 @@ You must also install VeraCrypt and ensure the VeraCrypt CLI is available on you Mount operations may require administrator or sudo permissions depending on your OS and system configuration. +## Privileges (Linux/macOS) + +By default, on Linux/macOS the wrapper prefixes VeraCrypt commands with `sudo`, preserving backward-compatible behavior. Two constructor parameters let you control this: + +- `use_sudo` (default `True`): whether to prefix commands with `sudo`. Mounting and dismounting volumes generally require root, so keep `sudo` (or run the process elevated) for those. **Creating a file container does not require root**, so pass `use_sudo=False` for least-privilege, non-interactive volume creation. +- `sudo_non_interactive` (default `False`): when `use_sudo=True`, invoke `sudo -n` so it never prompts for a password. This avoids `sudo`'s interactive password prompt consuming the volume password from `stdin` (which would otherwise cause the call to fail in headless/non-interactive contexts). + +```python +from veracrypt import VeraCrypt, FileSystem + +# Least-privilege, non-interactive file-container creation (no root needed): +vc = VeraCrypt(use_sudo=False) +vc.create_volume("/home/user/secure/test.vc", "SecretPassword", 5 * 1024 * 1024, filesystem=FileSystem.EXFAT) + +# Mounting needs root; keep sudo but never prompt (requires a passwordless sudo rule +# or an already-elevated process): +vc_mount = VeraCrypt(sudo_non_interactive=True) +vc_mount.mount_volume("/home/user/secure/test.vc", "SecretPassword", "/mnt/veracrypt") +``` + +These parameters are ignored on Windows. + ## Usage ```python diff --git a/build/lib/veracrypt/__about__.py b/build/lib/veracrypt/__about__.py new file mode 100644 index 0000000..23b2bfe --- /dev/null +++ b/build/lib/veracrypt/__about__.py @@ -0,0 +1,7 @@ +__title__ = "python-veracrypt" +__description__ = "A cross-platform Python wrapper for the VeraCrypt CLI." +__url__ = "https://github.com/srichs/python-veracrypt" +__version__ = "0.1.0" +__author__ = "srichs" +__author_email__ = "srichs@pm.me" +__license__ = "GNU GPL v3.0" diff --git a/build/lib/veracrypt/__init__.py b/build/lib/veracrypt/__init__.py new file mode 100644 index 0000000..20bd9c5 --- /dev/null +++ b/build/lib/veracrypt/__init__.py @@ -0,0 +1,3 @@ +from .veracrypt import Encryption, FileSystem, Hash, VeraCrypt, VeraCryptError + +__all__ = ["Encryption", "FileSystem", "Hash", "VeraCrypt", "VeraCryptError"] diff --git a/build/lib/veracrypt/veracrypt.py b/build/lib/veracrypt/veracrypt.py new file mode 100644 index 0000000..d1b6e58 --- /dev/null +++ b/build/lib/veracrypt/veracrypt.py @@ -0,0 +1,650 @@ +"""Python wrapper around the VeraCrypt CLI.""" + +import logging +import os +import platform +import subprocess +from enum import Enum +from typing import List, Optional, Tuple, Type + + +class Encryption(Enum): + """Supported VeraCrypt encryption algorithms.""" + + AES = "AES" + SERPENT = "Serpent" + TWOFISH = "Twofish" + CAMELLIA = "Camellia" + KUZNYECHIK = "Kuznyechik" + AES_TWOFISH = "AES(Twofish)" + TWOFISH_SERPENT = "Twofish(Serpent)" + SERPENT_AES = "Serpent(AES)" + SERPENT_TWOFISH_AES = "Serpent(Twofish(AES))" + AES_SERPENT = "AES(Serpent)" + KUZNYECHIK_CAMELLIA = "Kuznyechik(Camellia)" + CAMELLIA_KUZNYECHIK = "Camellia(Kuznyechik)" + + +class Hash(Enum): + """Supported VeraCrypt hash algorithms.""" + + SHA256 = "sha-256" + SHA512 = "sha-512" + WHIRLPOOL = "whirlpool" + BLAKE2S = "blake2s" + RIPEMD160 = "ripemd-160" + STREEBOG = "streebog" + + +class FileSystem(Enum): + """Supported filesystem formats for newly created volumes.""" + + NONE = "None" + FAT = "FAT" + EXFAT = "exFAT" + NTFS = "NTFS" + EXT2 = "ext2" + EXT3 = "ext3" + EXT4 = "ext4" + HFS = "HFS" + APFS = "APFS" + + +class VeraCryptError(RuntimeError): + """Raised when VeraCrypt operations fail.""" + + +class VeraCrypt(object): + """Cross-platform wrapper for core VeraCrypt CLI operations. + + The class wraps the VeraCrypt CLI to create, mount, and dismount volumes on Windows, + macOS, and Linux systems. Windows uses ``/param value`` arguments and separates the + mount/dismount tool (``VeraCrypt.exe``) from the format tool + (``VeraCrypt Format.exe``), + while Linux/macOS use ``--param value`` arguments against a single executable. + + Extra CLI options can be supplied to the public methods, but invalid options will + result in a VeraCrypt CLI error. + + The :meth:`command` method allows arbitrary command execution. On Windows, set + ``windows_program="VeraCrypt Format.exe"`` to target the volume creation CLI. + + **WARNING:** On Windows the VeraCrypt CLI does not accept passwords from stdin. The + password will be present in the subprocess arguments for the duration of the call. + The returned ``CompletedProcess.args`` has the password masked for logging safety. + + :param log_level: Logging level to use. Defaults to ``logging.ERROR``. + :param log_fmt: Logging format string. + :param log_datefmt: Date format string used in log messages. + :param veracrypt_path: Path to the VeraCrypt executable. On Windows, this should be + the directory containing the executables. On macOS/Linux, this should be the + full path to the VeraCrypt binary. If ``None``, a platform-specific default is + discovered. + :param use_sudo: On Linux/macOS, whether to prefix VeraCrypt commands with ``sudo``. + Defaults to ``True`` to preserve backward-compatible behavior. Mounting and + dismounting volumes typically require root, but file-container creation does + not, so pass ``use_sudo=False`` for least-privilege, non-interactive volume + creation. Ignored on Windows. + :param sudo_non_interactive: On Linux/macOS, when ``use_sudo`` is ``True``, whether + to invoke ``sudo`` with ``-n`` (non-interactive) so it never prompts for a + password. Defaults to ``False``. This avoids ``sudo``'s password prompt + consuming the volume password from stdin. Ignored on Windows or when + ``use_sudo`` is ``False``. + """ + + def __init__( + self, + log_level: Optional[int] = logging.ERROR, + log_fmt: Optional[str] = "%(levelname)s:%(module)s:%(funcName)s:%(message)s", + log_datefmt: Optional[str] = "%Y-%m-%d %H:%M:%S", + veracrypt_path: Optional[str] = None, + use_sudo: bool = True, + sudo_non_interactive: bool = False, + ): + if log_fmt is None: + log_fmt = "%(levelname)s:%(module)s:%(funcName)s:%(message)s" + if log_datefmt is None: + log_datefmt = "%Y-%m-%d %H:%M:%S" + logging.basicConfig(level=log_level, format=log_fmt, datefmt=log_datefmt) + self.logger = logging.getLogger("veracrypt.py") + self.os_name = platform.system() + self.veracrypt_path = veracrypt_path or self._default_path() + self.use_sudo = use_sudo + self.sudo_non_interactive = sudo_non_interactive + self.logger.info("Object initialized") + + def _privilege_prefix(self) -> List[str]: + """Return the privilege-escalation prefix for Linux/macOS commands. + + :return: ``[]`` when ``use_sudo`` is ``False``, ``["sudo", "-n"]`` when + ``sudo_non_interactive`` is ``True``, otherwise ``["sudo"]``. + """ + if not self.use_sudo: + return [] + return ["sudo", "-n"] if self.sudo_non_interactive else ["sudo"] + + def _validate_options(self, options: Optional[List[str]], context: str) -> None: + """Validate CLI options passed into public methods.""" + if options is None: + return + if not isinstance(options, list) or not all( + isinstance(item, str) for item in options + ): + raise ValueError( + f"{context} options must be a list of strings when provided." + ) + + def _validate_keyfiles(self, keyfiles: Optional[List[str]], context: str) -> None: + """Validate keyfile paths passed into public methods.""" + if keyfiles is None: + return + if not isinstance(keyfiles, list) or not all( + isinstance(item, str) for item in keyfiles + ): + raise ValueError( + f"{context} keyfiles must be a list of strings when provided." + ) + for keyfile in keyfiles: + self._check_path(keyfile) + + @staticmethod + def _validate_size(size: int) -> None: + """Validate volume size is a positive integer.""" + if not isinstance(size, int) or size <= 0: + raise ValueError("Volume size must be a positive integer.") + + @staticmethod + def _validate_enum(value: Enum, enum_cls: Type[Enum], name: str) -> None: + """Validate that an argument is an instance of a specific enum class.""" + if not isinstance(value, enum_cls): + raise ValueError(f"{name} must be an instance of {enum_cls.__name__}.") + + @staticmethod + def _validate_volume_parent_dir(volume_path: str) -> None: + """Ensure the parent directory for a volume path exists.""" + parent_dir = os.path.dirname(volume_path) or "." + if not os.path.exists(parent_dir): + raise VeraCryptError( + f"The parent directory for {volume_path} does not exist." + ) + + @staticmethod + def _mask_password_in_args(args: List[str], password: str, index: int) -> None: + """Safely mask a password in a command args list.""" + if 0 <= index < len(args): + args[index] = "*" * len(password) + + def _default_path(self) -> str: + """Return the default VeraCrypt CLI path for the current platform.""" + self.logger.debug("Getting default path") + + if self.os_name == "Windows": + path = os.path.join("C:\\", "Program Files", "VeraCrypt") + path1 = os.path.join(path, "VeraCrypt.exe") + path2 = os.path.join(path, "VeraCrypt Format.exe") + + if not os.path.exists(path1): + raise VeraCryptError(f"VeraCrypt.exe not found at {path1}") + if not os.path.exists(path2): + raise VeraCryptError(f'"VeraCrypt Format.exe" not found at {path2}') + elif self.os_name == "Darwin": # macOS + path = os.path.join( + "/", "Applications", "VeraCrypt.app", "Contents", "MacOS", "VeraCrypt" + ) + elif self.os_name == "Linux": + path = os.path.join("/", "usr", "bin", "veracrypt") + else: + raise VeraCryptError("Unsupported Operating System") + + self._check_path(path) + self.logger.info(f"VeraCrypt program path found at {path}") + return path + + def _check_path(self, path: str) -> bool: + """Validate that a path exists. + + :param path: Filesystem path to validate. + :raises VeraCryptError: If the path does not exist. + :return: ``True`` when the path exists. + """ + if os.path.exists(path): + self.logger.debug(f"Path {path} exists") + return True + else: + raise VeraCryptError(f"The path {path} does not exist") + + def mount_volume( + self, + volume_path: str, + password: str, + mount_point: Optional[str] = None, + options: Optional[List[str]] = None, + ) -> subprocess.CompletedProcess: + """Mount a VeraCrypt volume. + + :param volume_path: Path to the volume file to mount. + :param password: Password for the volume. + :param mount_point: Target mount point (drive letter on Windows). + :param options: Additional CLI options. Options differ by platform. + :raises VeraCryptError: If the CLI call fails. + :return: ``subprocess.CompletedProcess`` for the CLI invocation. + """ + self.logger.debug("Mounting volume") + self._validate_options(options, "mount_volume") + self._check_path(volume_path) + if self.os_name != "Windows" and mount_point: + self._check_path(mount_point) + + if self.os_name == "Windows": + cmd = self._mount_win(volume_path, password, mount_point, options) + else: + cmd = self._mount_nix(volume_path, mount_point, options) + self.logger.debug(f"Command created: {cmd}") + + try: + if self.os_name == "Windows": + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + self._mask_password_in_args(result.args, password, 4) + else: + result = subprocess.run( + cmd, + input=password + "\n", + capture_output=True, + text=True, + check=True, + ) + self.logger.info(f"Command executed: returned {result.returncode}") + self.logger.debug(f"{result.stdout}") + return result + except subprocess.CalledProcessError as e: + raise VeraCryptError(f"Error mounting volume: {e.stderr}") from e + + def _mount_win( + self, + volume_path: str, + password: str, + mount_point: Optional[str] = None, + options: Optional[List[str]] = None, + ) -> List[str]: + """Build the Windows CLI command for mounting a volume.""" + self.logger.debug("Mounting volume on Windows") + cmd = [ + os.path.join(self.veracrypt_path, "VeraCrypt.exe"), + "/volume", + volume_path, + "/password", + password, + ] + + if mount_point: + cmd += ["/letter", mount_point] + + if options: + cmd += options + cmd += ["/quit", "/silent", "/force"] + self.logger.debug("Mount command generated") + return cmd + + def _mount_nix( + self, + volume_path: str, + mount_point: Optional[str] = None, + options: Optional[List[str]] = None, + ) -> List[str]: + """Build the Linux/macOS CLI command for mounting a volume.""" + self.logger.debug("Mounting volume on Linux/MacOS") + cmd = [ + *self._privilege_prefix(), + self.veracrypt_path, + "--text", + "--non-interactive", + "--mount", + volume_path, + ] + + if mount_point: + cmd += [mount_point] + + if options: + cmd += options + cmd += ["--stdin", "--force"] + self.logger.debug("Mount command generated") + return cmd + + def dismount_volume( + self, target: str = "all", options: Optional[List[str]] = None + ) -> subprocess.CompletedProcess: + """Dismount a VeraCrypt volume or all mounted volumes. + + :param target: Mount point to dismount. Use ``"all"`` to dismount all volumes. + :param options: Additional CLI options. + :raises VeraCryptError: If the CLI call fails. + :return: ``subprocess.CompletedProcess`` for the CLI invocation. + """ + self.logger.debug("Dismounting volume") + self._validate_options(options, "dismount_volume") + + if self.os_name == "Windows": + cmd = self._dismount_win(target, options) + else: + cmd = self._dismount_nix(target, options) + self.logger.debug(f"Command created: {cmd}") + + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + self.logger.info(f"Command executed: returned {result.returncode}") + self.logger.debug(f"{result.stdout}") + return result + except subprocess.CalledProcessError as e: + raise VeraCryptError(f"Error dismounting volume: {e.stderr}") from e + + def _dismount_win( + self, target: str = "all", options: Optional[List[str]] = None + ) -> List[str]: + """Build the Windows CLI command for dismounting volumes.""" + self.logger.debug("Dismounting volume on Windows") + cmd = [os.path.join(self.veracrypt_path, "VeraCrypt.exe"), "/dismount"] + + if target != "all": + cmd.append(target) + + if options: + cmd += options + cmd += ["/quit", "/silent", "/force"] + self.logger.debug("Dismount command generated") + return cmd + + def _dismount_nix( + self, target: str = "all", options: Optional[List[str]] = None + ) -> List[str]: + """Build the Linux/macOS CLI command for dismounting volumes.""" + self.logger.debug("Dismounting volume on Linux/MacOS") + cmd = [ + *self._privilege_prefix(), + self.veracrypt_path, + "--text", + "--non-interactive", + "--unmount", + ] + + if target != "all": + self._check_path(target) + cmd.append(target) + + if options: + cmd += options + self.logger.debug("Dismount command generated") + return cmd + + def create_volume( + self, + volume_path: str, + password: str, + size: int, + encryption: Encryption = Encryption.AES, + hash_alg: Hash = Hash.SHA512, + filesystem: FileSystem = FileSystem.FAT, + keyfiles: Optional[List[str]] = None, + hidden: bool = False, + options: Optional[List[str]] = None, + ) -> subprocess.CompletedProcess: + """Create a new VeraCrypt volume. + + :param volume_path: Destination path for the volume file. + :param password: Password for the volume. + :param size: Volume size in bytes. + :param encryption: Encryption algorithm to use. + :param hash_alg: Hash algorithm to use. + :param filesystem: Filesystem format to apply. + :param keyfiles: Optional keyfiles to include in encryption. + :param hidden: Whether to create a hidden volume. + :param options: Additional CLI options. + :raises VeraCryptError: If the CLI call fails. + :return: ``subprocess.CompletedProcess`` for the CLI invocation. + """ + self.logger.debug("Creating volume") + self._validate_options(options, "create_volume") + self._validate_keyfiles(keyfiles, "create_volume") + self._validate_size(size) + self._validate_enum(encryption, Encryption, "encryption") + self._validate_enum(hash_alg, Hash, "hash_alg") + self._validate_enum(filesystem, FileSystem, "filesystem") + if self.os_name == "Linux": + self._validate_volume_parent_dir(volume_path) + + if self.os_name == "Windows": + cmd = self._create_win( + volume_path, + password, + size, + encryption, + hash_alg, + filesystem, + keyfiles, + options, + ) + else: + if self.os_name == "Darwin": + if not os.path.exists(volume_path): + with open(volume_path, "w"): + pass + cmd = self._create_nix( + volume_path, + size, + encryption, + hash_alg, + filesystem, + keyfiles, + hidden, + options, + ) + self.logger.debug(f"Command created: {cmd}") + + try: + result = None + if self.os_name == "Windows": + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + self._mask_password_in_args(result.args, password, 4) + else: + result = subprocess.run( + cmd, + input=password + "\n", + capture_output=True, + text=True, + check=True, + ) + self.logger.info(f"Command executed: returned {result.returncode}") + self.logger.debug(f"{result.stdout}") + return result + except subprocess.CalledProcessError as e: + raise VeraCryptError(f"Error creating volume: {e.stderr}") from e + + def _create_win( + self, + volume_path: str, + password: str, + size: int, + encryption: Encryption = Encryption.AES, + hash_alg: Hash = Hash.SHA512, + filesystem: FileSystem = FileSystem.FAT, + keyfiles: Optional[List[str]] = None, + options: Optional[List[str]] = None, + ) -> List[str]: + """Build the Windows CLI command for creating a volume.""" + self.logger.debug("Creating volume on Windows") + cmd = [ + os.path.join(self.veracrypt_path, "VeraCrypt Format.exe"), + "/create", + volume_path, + "/password", + password, + "/size", + f"{size}", + "/encryption", + encryption.value, + "/hash", + hash_alg.value, + "/filesystem", + filesystem.value, + ] + + if keyfiles: + for keyfile in keyfiles: + cmd += ["/keyfile", keyfile] + + if options: + cmd += options + cmd += ["/protectMemory", "/quick", "/silent", "/force"] + self.logger.debug("Create command generated") + return cmd + + def _create_nix( + self, + volume_path: str, + size: int, + encryption: Encryption = Encryption.AES, + hash_alg: Hash = Hash.SHA512, + filesystem: FileSystem = FileSystem.FAT, + keyfiles: Optional[List[str]] = None, + hidden: bool = False, + options: Optional[List[str]] = None, + ) -> List[str]: + """Build the Linux/macOS CLI command for creating a volume.""" + self.logger.debug("Creating volume on Linux/MacOS") + cmd = [ + *self._privilege_prefix(), + self.veracrypt_path, + "--text", + "--non-interactive", + "--create", + volume_path, + "--size", + f"{size}", + "--encryption", + encryption.value, + "--hash", + hash_alg.value, + "--filesystem", + filesystem.value, + ] + + if keyfiles: + for keyfile in keyfiles: + cmd += ["--keyfiles", keyfile] + + if hidden: + cmd += ["--volume-type", "hidden"] + + if options: + cmd += options + cmd += ["--random-source", "/dev/urandom", "--stdin", "--quick", "--force"] + self.logger.debug("Create command generated") + return cmd + + def command( + self, + options: Optional[List[str]] = None, + windows_program: str = "VeraCrypt.exe", + ) -> subprocess.CompletedProcess: + """Call the VeraCrypt CLI with custom options. + + :param options: Options to pass to the VeraCrypt CLI. + :param windows_program: Windows-only program name to invoke. + :raises VeraCryptError: If the CLI call fails. + :return: ``subprocess.CompletedProcess`` for the CLI invocation. + """ + self.logger.debug("Calling custom command") + self._validate_options(options, "command") + if self.os_name == "Windows": + cmd = self._custom_win(options, windows_program) + password, index = self._get_password(cmd) + else: + password, index = self._get_password(options) + cmd = self._custom_nix(options) + self.logger.debug(f"Command created: {cmd}") + + try: + result = None + if self.os_name == "Windows": + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + if password is not None: + self.logger.debug("Sanitizing password") + self._mask_password_in_args(result.args, password, index) + else: + if password is not None: + result = subprocess.run( + cmd, + input=password + "\n", + capture_output=True, + text=True, + check=True, + ) + else: + result = subprocess.run( + cmd, capture_output=True, text=True, check=True + ) + self.logger.info(f"Command executed: returned {result.returncode}") + self.logger.debug(f"{result.stdout}") + return result + except subprocess.CalledProcessError as e: + raise VeraCryptError(f"Error calling custom command: {e.stderr}") from e + + def _custom_win( + self, + options: Optional[List[str]] = None, + windows_program: str = "VeraCrypt.exe", + ) -> List[str]: + """Build a Windows CLI command using an arbitrary VeraCrypt executable.""" + self.logger.debug("Calling custom command on Windows") + cmd = [os.path.join(self.veracrypt_path, windows_program)] + + if options: + cmd += options + self.logger.debug("Custom command generated") + return cmd + + def _custom_nix(self, options: Optional[List[str]] = None) -> List[str]: + """Build a Linux/macOS CLI command using provided options.""" + self.logger.debug("Calling custom command on Linux/MacOS") + options_list = list(options) if options else [] + password, p_index = self._get_password(options_list) + cmd = [*self._privilege_prefix(), self.veracrypt_path] + + if password and options_list: + self.logger.debug("Removing password from command line options") + del options_list[p_index - 1 : p_index + 1] + + if options_list: + cmd += options_list + + if password: + if "--stdin" not in options_list: + cmd += ["--stdin"] + self.logger.debug("Custom command generated") + return cmd + + def _get_password(self, cmd: Optional[List[str]]) -> Tuple[Optional[str], int]: + """Extract a password argument from a command list. + + :param cmd: Command list or ``None``. + :return: Tuple of password value and index where it was found. + """ + if not cmd: + return None, -1 + pword_option = "--password" + if self.os_name == "Windows": + pword_option = "/password" + + try: + option_index = cmd.index(pword_option) + except ValueError: + return None, -1 + + try: + index = option_index + 1 + pword = cmd[index] + if pword == "": + raise ValueError("Password option provided without a value.") + except IndexError as exc: + raise ValueError("Password option provided without a value.") from exc + return pword, index diff --git a/pyproject.toml b/pyproject.toml index a1c7c2e..78c9c2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-veracrypt" -version = "0.1.2" +version = "0.2.0" description = "A cross-platform Python wrapper for the VeraCrypt CLI." readme = "README.md" requires-python = ">=3.11" diff --git a/src/veracrypt/__about__.py b/src/veracrypt/__about__.py index 23b2bfe..2b12d2e 100644 --- a/src/veracrypt/__about__.py +++ b/src/veracrypt/__about__.py @@ -1,7 +1,7 @@ __title__ = "python-veracrypt" __description__ = "A cross-platform Python wrapper for the VeraCrypt CLI." __url__ = "https://github.com/srichs/python-veracrypt" -__version__ = "0.1.0" +__version__ = "0.2.0" __author__ = "srichs" __author_email__ = "srichs@pm.me" __license__ = "GNU GPL v3.0" diff --git a/src/veracrypt/veracrypt.py b/src/veracrypt/veracrypt.py index f16d6f4..d1b6e58 100644 --- a/src/veracrypt/veracrypt.py +++ b/src/veracrypt/veracrypt.py @@ -80,6 +80,16 @@ class VeraCrypt(object): the directory containing the executables. On macOS/Linux, this should be the full path to the VeraCrypt binary. If ``None``, a platform-specific default is discovered. + :param use_sudo: On Linux/macOS, whether to prefix VeraCrypt commands with ``sudo``. + Defaults to ``True`` to preserve backward-compatible behavior. Mounting and + dismounting volumes typically require root, but file-container creation does + not, so pass ``use_sudo=False`` for least-privilege, non-interactive volume + creation. Ignored on Windows. + :param sudo_non_interactive: On Linux/macOS, when ``use_sudo`` is ``True``, whether + to invoke ``sudo`` with ``-n`` (non-interactive) so it never prompts for a + password. Defaults to ``False``. This avoids ``sudo``'s password prompt + consuming the volume password from stdin. Ignored on Windows or when + ``use_sudo`` is ``False``. """ def __init__( @@ -88,6 +98,8 @@ def __init__( log_fmt: Optional[str] = "%(levelname)s:%(module)s:%(funcName)s:%(message)s", log_datefmt: Optional[str] = "%Y-%m-%d %H:%M:%S", veracrypt_path: Optional[str] = None, + use_sudo: bool = True, + sudo_non_interactive: bool = False, ): if log_fmt is None: log_fmt = "%(levelname)s:%(module)s:%(funcName)s:%(message)s" @@ -97,8 +109,20 @@ def __init__( self.logger = logging.getLogger("veracrypt.py") self.os_name = platform.system() self.veracrypt_path = veracrypt_path or self._default_path() + self.use_sudo = use_sudo + self.sudo_non_interactive = sudo_non_interactive self.logger.info("Object initialized") + def _privilege_prefix(self) -> List[str]: + """Return the privilege-escalation prefix for Linux/macOS commands. + + :return: ``[]`` when ``use_sudo`` is ``False``, ``["sudo", "-n"]`` when + ``sudo_non_interactive`` is ``True``, otherwise ``["sudo"]``. + """ + if not self.use_sudo: + return [] + return ["sudo", "-n"] if self.sudo_non_interactive else ["sudo"] + def _validate_options(self, options: Optional[List[str]], context: str) -> None: """Validate CLI options passed into public methods.""" if options is None: @@ -270,7 +294,7 @@ def _mount_nix( """Build the Linux/macOS CLI command for mounting a volume.""" self.logger.debug("Mounting volume on Linux/MacOS") cmd = [ - "sudo", + *self._privilege_prefix(), self.veracrypt_path, "--text", "--non-interactive", @@ -335,7 +359,13 @@ def _dismount_nix( ) -> List[str]: """Build the Linux/macOS CLI command for dismounting volumes.""" self.logger.debug("Dismounting volume on Linux/MacOS") - cmd = ["sudo", self.veracrypt_path, "--text", "--non-interactive", "--unmount"] + cmd = [ + *self._privilege_prefix(), + self.veracrypt_path, + "--text", + "--non-interactive", + "--unmount", + ] if target != "all": self._check_path(target) @@ -482,7 +512,7 @@ def _create_nix( """Build the Linux/macOS CLI command for creating a volume.""" self.logger.debug("Creating volume on Linux/MacOS") cmd = [ - "sudo", + *self._privilege_prefix(), self.veracrypt_path, "--text", "--non-interactive", @@ -578,7 +608,7 @@ def _custom_nix(self, options: Optional[List[str]] = None) -> List[str]: self.logger.debug("Calling custom command on Linux/MacOS") options_list = list(options) if options else [] password, p_index = self._get_password(options_list) - cmd = ["sudo", self.veracrypt_path] + cmd = [*self._privilege_prefix(), self.veracrypt_path] if password and options_list: self.logger.debug("Removing password from command line options") diff --git a/tests/test_veracrypt.py b/tests/test_veracrypt.py index 6538cdb..a798ea6 100644 --- a/tests/test_veracrypt.py +++ b/tests/test_veracrypt.py @@ -189,6 +189,78 @@ def test_create_nix_hidden_volume(self): self.assertIn("sha-256", cmd) self.assertIn("ext4", cmd) + def test_privilege_prefix_default_uses_sudo(self): + self.assertEqual(self.veracrypt._privilege_prefix(), ["sudo"]) + + def test_privilege_prefix_no_sudo_is_empty(self): + vc = VeraCrypt(veracrypt_path="/usr/bin/veracrypt", use_sudo=False) + self.assertEqual(vc._privilege_prefix(), []) + + def test_privilege_prefix_non_interactive_adds_dash_n(self): + vc = VeraCrypt(veracrypt_path="/usr/bin/veracrypt", sudo_non_interactive=True) + self.assertEqual(vc._privilege_prefix(), ["sudo", "-n"]) + + def test_privilege_prefix_no_sudo_ignores_non_interactive(self): + vc = VeraCrypt( + veracrypt_path="/usr/bin/veracrypt", + use_sudo=False, + sudo_non_interactive=True, + ) + self.assertEqual(vc._privilege_prefix(), []) + + def test_create_nix_default_starts_with_sudo(self): + self.veracrypt.os_name = "Linux" + self.veracrypt.veracrypt_path = "/usr/bin/veracrypt" + + cmd = self.veracrypt._create_nix("/vol", 1024) + + self.assertEqual(cmd[0], "sudo") + self.assertEqual(cmd[1], "/usr/bin/veracrypt") + + def test_create_nix_no_sudo_starts_with_veracrypt_path(self): + vc = VeraCrypt(veracrypt_path="/usr/bin/veracrypt", use_sudo=False) + vc.os_name = "Linux" + + cmd = vc._create_nix("/vol", 1024) + + self.assertNotIn("sudo", cmd) + self.assertEqual(cmd[0], "/usr/bin/veracrypt") + + def test_create_nix_non_interactive_yields_sudo_dash_n(self): + vc = VeraCrypt(veracrypt_path="/usr/bin/veracrypt", sudo_non_interactive=True) + vc.os_name = "Linux" + + cmd = vc._create_nix("/vol", 1024) + + self.assertEqual(cmd[:2], ["sudo", "-n"]) + self.assertEqual(cmd[2], "/usr/bin/veracrypt") + + def test_mount_nix_no_sudo_omits_sudo(self): + vc = VeraCrypt(veracrypt_path="/usr/bin/veracrypt", use_sudo=False) + vc.os_name = "Linux" + + cmd = vc._mount_nix("/vol") + + self.assertNotIn("sudo", cmd) + self.assertEqual(cmd[0], "/usr/bin/veracrypt") + + def test_dismount_nix_non_interactive_yields_sudo_dash_n(self): + vc = VeraCrypt(veracrypt_path="/usr/bin/veracrypt", sudo_non_interactive=True) + vc.os_name = "Linux" + + cmd = vc._dismount_nix() + + self.assertEqual(cmd[:2], ["sudo", "-n"]) + self.assertIn("--unmount", cmd) + + def test_custom_nix_no_sudo_starts_with_veracrypt_path(self): + vc = VeraCrypt(veracrypt_path="/usr/bin/veracrypt", use_sudo=False) + vc.os_name = "Linux" + + cmd = vc._custom_nix() + + self.assertEqual(cmd, ["/usr/bin/veracrypt"]) + def test_default_path_linux_uses_check_path(self): self.veracrypt.os_name = "Linux" with patch.object(self.veracrypt, "_check_path", return_value=True) as mock: