diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 53dff799..c5038b19 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -25,6 +25,8 @@ on: - 'Tests/test.*.hc' - 'Tests/test_volume_read.py' - 'Tests/volume_read_test.cpp' + - 'Tests/test_linux_mount_read_only.py' + - 'Tests/linux_mount_read_only_test.cpp' - 'Tests/response_apdu_test.cpp' - '.github/workflows/build-linux.yml' pull_request: @@ -51,6 +53,8 @@ on: - 'Tests/test.*.hc' - 'Tests/test_volume_read.py' - 'Tests/volume_read_test.cpp' + - 'Tests/test_linux_mount_read_only.py' + - 'Tests/linux_mount_read_only_test.cpp' - 'Tests/response_apdu_test.cpp' - '.github/workflows/build-linux.yml' @@ -150,6 +154,10 @@ jobs: - name: Read known data from the test volumes run: python3 Tests/test_volume_read.py --build-dir src --require-thread-pool + # The package build above defaults to FUSE2 and leaves matching archives. + - name: Check Linux mount protection and fallback + run: python3 Tests/test_linux_mount_read_only.py --build-dir src --fuse-package=fuse + - name: Install and test VeraCrypt GUI .deb packages run: | sudo apt install -y /tmp/VeraCrypt_Packaging/GUI/Packaging/veracrypt-*.deb diff --git a/Tests/linux_mount_read_only_test.cpp b/Tests/linux_mount_read_only_test.cpp index c1d0607a..9fc3f289 100644 --- a/Tests/linux_mount_read_only_test.cpp +++ b/Tests/linux_mount_read_only_test.cpp @@ -10,6 +10,9 @@ using namespace VeraCrypt; static bool ReadOnly; static unsigned LoopCalls, LoopFailures, Layers, ExpectedLayers; static string VolumeFilePath; +static bool MountFlow, AuxInfoSent; +static string AuxPath, LoopFilePath; +static unsigned FuseMountCalls; struct StopBeforeDeviceIO { }; static void Require (bool condition, const char *message) @@ -31,7 +34,7 @@ extern "C" string WrapExecute (const string &program, const list &args, if (ReadOnly) expected.push_back ("-r"); expected.push_back ("--"); expected.push_back ("/dev/loop" + StringConverter::ToSingle (LoopCalls - 1)); - expected.push_back (VolumeFilePath); + expected.push_back (MountFlow ? LoopFilePath : VolumeFilePath); Require (a == expected, "loop retry changed protection or arguments"); if (LoopCalls <= LoopFailures) throw ExecutedProcessFailed (SRC_POS, "losetup", 1, "injected failure"); @@ -53,8 +56,10 @@ extern "C" string WrapExecute (const string &program, const list &args, } extern "C" string WrapFindBinary (const char *, string &) { return "/test/dmsetup"; } -extern "C" FilesystemPathType::Enum WrapGetType (const FilesystemPath *) +extern "C" FilesystemPathType::Enum WrapGetType (const FilesystemPath *path) { + if (MountFlow && string (*path).find ("/dev/loop") != 0) + return FilesystemPathType::File; return FilesystemPathType::BlockDevice; } extern "C" shared_ptr WrapAuxInfo (const DirectoryPath &) @@ -64,6 +69,22 @@ extern "C" shared_ptr WrapAuxInfo (const DirectoryPath &) return info; } +extern "C" uint64 WrapFuseMount (shared_ptr, VolumeSlotNumber, const string &path) +{ + Require (MountFlow, "unexpected FUSE service mount"); + ++FuseMountCalls; + AuxPath = path; + LoopFilePath = path + "/volume"; + return 987654321; +} + +extern "C" void WrapSendAuxInfo (const DirectoryPath &path, const DevicePath &device, const DevicePath &loop) +{ + Require (MountFlow && string (path) == AuxPath && string (device) == "/dev/loop0" + && device == loop, "unexpected FUSE loop metadata"); + AuxInfoSent = true; +} + struct TestLayout : VolumeLayoutV2Normal { uint64 GetDataOffset (uint64) const override { return 128 * 1024; } @@ -98,6 +119,37 @@ struct TestCore : CoreLinux using CoreUnix::MountAuxVolumeImage; }; +// Exercise MountVolume -> MountVolumeNative -> MountAuxVolumeImage with a +// synthetic opened volume. Only FUSE service creation and final filesystem I/O +// are intercepted; backend selection and loop arguments use production code. +struct FallbackCore : TestCore +{ + shared_ptr Opened; + mutable bool FilesystemMounted = false; + + shared_ptr OpenVolume (shared_ptr, bool, shared_ptr, int, + shared_ptr, shared_ptr, bool, VolumeProtection::Enum, + shared_ptr, int, shared_ptr, shared_ptr, + bool, VolumeType::Enum, bool, bool) const override { return Opened; } + bool IsVolumeMounted (const VolumePath &) const override { return false; } + VolumeInfoList GetMountedVolumes (const VolumePath & = VolumePath()) const override { return VolumeInfoList(); } + shared_ptr GetMountedVolume (const VolumePath &) const override + { + return shared_ptr (new VolumeInfo); + } + shared_ptr DismountVolume (shared_ptr info, bool, bool) override { return info; } + MountedFilesystemList GetMountedFilesystems (const DevicePath & = DevicePath(), + const DirectoryPath & = DirectoryPath()) const override { return MountedFilesystemList(); } + string GetTempDirectory () const override { return VolumeFilePath.substr (0, VolumeFilePath.rfind ('/')); } + void MountFilesystem (const DevicePath &device, const DirectoryPath &, const string &, + bool readOnly, const string &, bool) const override + { + Require (string (device) == "/dev/loop0" && readOnly == ReadOnly, + "FUSE filesystem lost effective protection"); + FilesystemMounted = true; + } +}; + int main (int argc, char **argv) { try @@ -153,6 +205,36 @@ int main (int argc, char **argv) Require (Layers == 0, "FUSE fallback created a native mapping"); } + MountFlow = true; + for (unsigned scenario = 0; scenario < 4; ++scenario) + for (bool noFilesystem : {false, true}) + { + const bool incomplete = scenario == 0; + const VolumeProtection::Enum protection = scenario == 1 ? VolumeProtection::HiddenVolumeReadOnly + : scenario == 3 ? VolumeProtection::None : VolumeProtection::ReadOnly; + FallbackCore fallback; + fallback.Opened.reset (new TestVolume (false, protection, incomplete)); + MountOptions mountOptions; + mountOptions.Path.reset (new VolumePath (VolumeFilePath)); + mountOptions.MountPoint.reset (new DirectoryPath (fallback.GetTempDirectory() + "/mount")); + mountOptions.SlotNumber = 64; + mountOptions.NoFilesystem = noFilesystem; + mountOptions.NoKernelCrypto = scenario >= 2; + mountOptions.FilesystemType = L"vfat"; + mountOptions.Protection = incomplete ? VolumeProtection::None : protection; + ReadOnly = protection == VolumeProtection::ReadOnly; + LoopCalls = LoopFailures = Layers = FuseMountCalls = 0; + AuxInfoSent = false; + AuxPath.clear(); + fallback.MountVolume (mountOptions); + Require (mountOptions.Protection == protection, "effective protection not propagated or hidden protection changed"); + Require (FuseMountCalls == 1 && LoopCalls == 1 && Layers == 0 && AuxInfoSent, + "FUSE fallback not completed"); + Require (fallback.FilesystemMounted == !noFilesystem, "FUSE filesystem path not exercised"); + Require (rmdir (AuxPath.c_str()) == 0, "cannot remove test auxiliary directory"); + } + MountFlow = false; + // The FUSE image caller must propagate loop setup failure as well. options.Protection = VolumeProtection::ReadOnly; VolumeFilePath = "/test/aux/volume"; @@ -166,7 +248,7 @@ int main (int argc, char **argv) } catch (LoopDeviceSetupFailed &) { } Require (LoopCalls == 256, "FUSE loop failure not exercised"); - std::cout << "PASS mapping arguments, cascades, effective protection, hidden/incomplete fallback, loop retries and failure\n"; + std::cout << "PASS mapping arguments, cascades, effective protection through FUSE fallback, hidden protection, loop retries and failure\n"; } catch (const std::exception &e) { std::cerr << e.what() << '\n'; return 1; } } diff --git a/Tests/test_linux_mount_read_only.py b/Tests/test_linux_mount_read_only.py index 04dd7cf7..7082f375 100644 --- a/Tests/test_linux_mount_read_only.py +++ b/Tests/test_linux_mount_read_only.py @@ -33,6 +33,8 @@ def main(): "Process16FindSystemBinaryE": "WrapFindBinary", "FilesystemPath7GetTypeE": "WrapGetType", "CoreUnix23ReadAuxiliaryVolumeInfoE": "WrapAuxInfo", + "FuseService5MountE": "WrapFuseMount", + "FuseService17SendAuxDeviceInfoE": "WrapSendAuxInfo", } flags = [] for fragment, wrapper in wrappers.items(): @@ -55,6 +57,9 @@ def main(): "-Wl,--start-group", *map(str, archives), "-Wl,--end-group", "-pthread", "-ldl", "-o", str(binary), ] + # WXSTATIC builds use the old libstdc++ ABI (see test_volume_read.py). + if "cxx11" not in symbols: + command.append("-D_GLIBCXX_USE_CXX11_ABI=0") command += shlex.split(subprocess.check_output( ["pkg-config", "--libs", args.fuse_package], text=True)) subprocess.run(command, check=True, timeout=120) diff --git a/Tests/test_linux_read_only_integration.py b/Tests/test_linux_read_only_integration.py index 8228ec67..fef29d13 100644 --- a/Tests/test_linux_read_only_integration.py +++ b/Tests/test_linux_read_only_integration.py @@ -1,7 +1,11 @@ #!/usr/bin/env python3 """Verify Linux read-only device protection in a dedicated disposable VM. -Requires the same tools as test_linux_loop_identity.py, plus blockdev. Run: +Requires the same tools as test_linux_loop_identity.py, plus blockdev and +debugfs. Read-only cases reject raw writes and preserve the encrypted host's +SHA-256. Dirty ext4 cases must refuse journal recovery without changing the +host or leaking devices; an explicit noload mount is the positive control. +Run: python3 Tests/test_linux_read_only_integration.py --binary /usr/bin/veracrypt To also test a virtual block device, attach a blank 16 MiB virtio disk with @@ -11,6 +15,8 @@ Use test_linux_mount_read_only.py for command/failure injection without root. """ import argparse +import errno +import hashlib import os from pathlib import Path import shutil @@ -22,6 +28,36 @@ from test_linux_loop_identity import ( ) +def host_digest(path): + digest = hashlib.sha256() + with open(path, "rb") as stream: + for block in iter(lambda: stream.read(1 << 20), b""): + digest.update(block) + return digest.hexdigest() + + +def check_write_rejected(checks, label, device): + # Establish readability separately, so a read error cannot be mistaken + # for a rejected write. Reusing the sector avoids corrupting later checks + # if read-only protection regresses. + with open(device, "rb", buffering=0) as stream: + sector = stream.read(512) + if len(sector) != 512: + raise AssertionError(f"Cannot read test sector from {device}") + descriptor = None + try: + descriptor = os.open(device, os.O_RDWR) + os.pwrite(descriptor, sector, 0) + except OSError as error: + checks.expect(label, error.errno in (errno.EROFS, errno.EPERM, errno.EACCES), + f"{device}: {error}") + else: + checks.expect(label, False, f"{device}: raw write accepted") + finally: + if descriptor is not None: + os.close(descriptor) + + def check_setup_failure(checks, program, kernel): label = f"{program}-failure-{'native' if kernel else 'fuse'}" real = checks.root / (program + ".real") @@ -77,6 +113,7 @@ def check(checks, path, kernel, readonly, filesystem, layers, effective_readonly expected_readonly = readonly if effective_readonly is None else effective_readonly if expected_readonly != readonly: label += "-access-fallback" + before_digest = host_digest(path) if expected_readonly else None before_aux = aux_mounts() before_loops = loop_devices() options = ",".join((["ro"] if readonly else []) + ([] if kernel else ["nokernelcrypto"])) @@ -104,6 +141,8 @@ def check(checks, path, kernel, readonly, filesystem, layers, effective_readonly for index, dev in enumerate(sorted(set(devices) | new_loops)): mode = checks.run(f"{label}-mode-{index}", ["blockdev", "--getro", dev]).stdout.strip() checks.expect(f"{label}-protected-{index}", mode == str(int(expected_readonly)), f"{dev}: read-only={mode}") + if expected_readonly: + check_write_rejected(checks, f"{label}-write-rejected-{index}", dev) if filesystem: flags = next(line.split()[3].split(",") for line in Path("/proc/mounts").read_text().splitlines() if line.split()[1] == str(checks.mountpoint)) @@ -117,6 +156,53 @@ def check(checks, path, kernel, readonly, filesystem, layers, effective_readonly checks.mounted = None checks.expect(label + "-released", *checks.released()) checks.expect(label + "-aux-released", aux_mounts() == before_aux, "auxiliary mount released") + if expected_readonly: + checks.expect(label + "-unchanged", host_digest(path) == before_digest, + "encrypted host SHA-256 unchanged") + finally: + checks.cleanup() + + +def check_journal_recovery(checks, path, kernel): + label = f"{path.name}-{'native' if kernel else 'fuse'}-journal" + args = ["--mount", path, checks.mountpoint, f"--slot={checks.slot}", + "--password=" + checks.password, "--pim=1", "--keyfiles=", "--protect-hidden=no"] + backend = "" if kernel else "nokernelcrypto" + try: + checks.vc(label + "-prepare", *args, "--filesystem=none", "--mount-options=" + backend) + checks.mounted = path + device = checks.listed()["device"] + checks.run(label + "-mkfs", ["mkfs.ext4", "-q", "-F", device]) + checks.run(label + "-mark-dirty", ["debugfs", "-w", "-R", "feature needs_recovery", device]) + features = checks.run(label + "-features", ["debugfs", "-R", "feature", device]).stdout.split() + if "needs_recovery" not in features: + raise AssertionError(f"Journal fixture is not marked for recovery: {features}") + checks.vc(label + "-prepare-unmount", "--unmount", path) + checks.mounted = None + before = host_digest(path) + readonly = "--mount-options=ro" + ("," + backend if backend else "") + result = checks.vc(label + "-mount", *args, "--filesystem=ext4", readonly, expected=None) + if result.returncode == 0: + checks.mounted = path + checks.expect(label + "-recovery-refused", result.returncode != 0, + f"exit {result.returncode}: {result.stdout + result.stderr}") + checks.expect(label + "-released", *checks.released()) + checks.cleanup() + checks.expect(label + "-unchanged", host_digest(path) == before, + "failed read-only mount preserved encrypted host SHA-256") + + # The filesystem is mountable when journal recovery is explicitly + # disabled. This is a test control, not an automatic recovery policy. + checks.vc(label + "-noload", *args, "--filesystem=ext4", readonly, "--fs-options=noload") + checks.mounted = path + flags = next(line.split()[3].split(",") for line in Path("/proc/mounts").read_text().splitlines() + if line.split()[1] == str(checks.mountpoint)) + checks.expect(label + "-noload-readonly", "ro" in flags, str(flags)) + checks.vc(label + "-noload-unmount", "--unmount", path) + checks.mounted = None + checks.expect(label + "-noload-released", *checks.released()) + checks.expect(label + "-noload-unchanged", host_digest(path) == before, + "explicit noload mount preserved encrypted host SHA-256") finally: checks.cleanup() @@ -130,7 +216,7 @@ def main(): args.legacy_binary = None if os.geteuid() != 0: parser.error("Run as root in a dedicated disposable VM") - missing = [tool for tool in (*TOOLS, "blockdev") if not shutil.which(tool)] + missing = [tool for tool in (*TOOLS, "blockdev", "debugfs") if not shutil.which(tool)] if missing: parser.error("Missing tools: " + ", ".join(missing)) if args.disposable_device: @@ -164,6 +250,9 @@ def main(): for kernel in (True, False): check_setup_failure(checks, "losetup", kernel) check_setup_failure(checks, "dmsetup", True) + for source in [checks.volume] + ([args.disposable_device] if args.disposable_device else []): + for kernel in (True, False): + check_journal_recovery(checks, source, kernel) if checks.failures: sys.exit("Failed: " + ", ".join(checks.failures)) print("All read-only integration checks passed") diff --git a/doc/html/en/Command Line Usage for Unix.html b/doc/html/en/Command Line Usage for Unix.html index bf9e3262..726b38d0 100644 --- a/doc/html/en/Command Line Usage for Unix.html +++ b/doc/html/en/Command Line Usage for Unix.html @@ -173,7 +173,7 @@ --fs-options=OPTIONS -Filesystem mount options passed to the system mount command with -o. This option is available on Linux and other Unix-like builds where supported, but not on macOS. +Filesystem mount options passed to the system mount command with -o. This option is available on Linux and other Unix-like builds where supported, but not on macOS. To protect the decrypted device against writes, use --mount-options=ro; filesystem-only --fs-options=ro can still allow journal recovery writes. --hash=HASH @@ -197,7 +197,7 @@ -m OPTION1[,OPTION2,...] or --mount-options=OPTION1[,OPTION2,...] -Set VeraCrypt volume mount options. Supported options are headerbak, nokernelcrypto, readonly or ro, system, and timestamp or ts. On Linux, kernelntfs enables in-kernel NTFS driver selection for the current mount when NTFS is detected and no filesystem type was supplied. +Set VeraCrypt volume mount options. Supported options are headerbak, nokernelcrypto, readonly or ro, system, and timestamp or ts. On Linux, kernelntfs enables in-kernel NTFS driver selection for the current mount when NTFS is detected and no filesystem type was supplied. Read-only protection applies to the decrypted device as well as the filesystem. A filesystem that needs journal recovery writes may refuse a read-only mount. --new-hash=HASH diff --git a/src/Core/Unix/CoreUnix.cpp b/src/Core/Unix/CoreUnix.cpp index 9e0dc9f9..ec59e501 100644 --- a/src/Core/Unix/CoreUnix.cpp +++ b/src/Core/Unix/CoreUnix.cpp @@ -1239,6 +1239,11 @@ namespace VeraCrypt break; } + // Opening a partially encrypted system volume can force read-only + // protection. Carry that effective mode into either mount backend. + if (volume->GetProtectionType() == VolumeProtection::ReadOnly) + options.Protection = VolumeProtection::ReadOnly; + if (options.Path->IsDevice()) { const uint32 devSectorSize = volume->GetFile()->GetDeviceSectorSize(); diff --git a/src/Main/UserInterface.cpp b/src/Main/UserInterface.cpp index e9798195..b3899904 100644 --- a/src/Main/UserInterface.cpp +++ b/src/Main/UserInterface.cpp @@ -1536,6 +1536,8 @@ const FileManager fileManagers[] = { " Filesystem mount options. The OPTIONS argument is passed to mount(8)\n" " command with option -o when a filesystem on a VeraCrypt volume is mounted.\n" " This option is not available on some platforms.\n" + " Use --mount-options=ro for device read-only protection; filesystem-only\n" + " ro can still allow journal recovery writes.\n" "\n" "--hash=HASH\n" " Use specified header key derivation algorithm when creating a new volume\n" @@ -1573,6 +1575,7 @@ const FileManager fileManagers[] = { " detected and no filesystem type was supplied.\n" #endif " See also option --fs-options.\n" + " A filesystem requiring journal recovery writes may refuse a read-only mount.\n" "\n" "--new-keyfiles=KEYFILE1[,KEYFILE2,KEYFILE3,...]\n" " Add specified keyfiles to a volume. This option can only be used with command\n"