mirror of
https://github.com/veracrypt/VeraCrypt.git
synced 2026-10-22 14:22:36 +00:00
Unix: honour forced read-only protection in FUSE mounts
Volume::Open forces read-only protection on a partially encrypted system volume, but the FUSE backend used the requested protection and attached the loop device and mounted the filesystem read-write. Copy the effective protection into the mount options before choosing the backend. Run the linked mount test in CI, check raw writes and journal recovery in the VM test, and document that a filesystem needing journal recovery may refuse a read-only mount.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<string> &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<string> &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<VolumeInfo> WrapAuxInfo (const DirectoryPath &)
|
||||
@@ -64,6 +69,22 @@ extern "C" shared_ptr<VolumeInfo> WrapAuxInfo (const DirectoryPath &)
|
||||
return info;
|
||||
}
|
||||
|
||||
extern "C" uint64 WrapFuseMount (shared_ptr<Volume>, 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<Volume> Opened;
|
||||
mutable bool FilesystemMounted = false;
|
||||
|
||||
shared_ptr<Volume> OpenVolume (shared_ptr<VolumePath>, bool, shared_ptr<VolumePassword>, int,
|
||||
shared_ptr<Pkcs5Kdf>, shared_ptr<KeyfileList>, bool, VolumeProtection::Enum,
|
||||
shared_ptr<VolumePassword>, int, shared_ptr<Pkcs5Kdf>, shared_ptr<KeyfileList>,
|
||||
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<VolumeInfo> GetMountedVolume (const VolumePath &) const override
|
||||
{
|
||||
return shared_ptr<VolumeInfo> (new VolumeInfo);
|
||||
}
|
||||
shared_ptr<VolumeInfo> DismountVolume (shared_ptr<VolumeInfo> 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; }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td><em>--fs-options=OPTIONS</em></td>
|
||||
<td>Filesystem mount options passed to the system mount command with <code>-o</code>. This option is available on Linux and other Unix-like builds where supported, but not on macOS.</td>
|
||||
<td>Filesystem mount options passed to the system mount command with <code>-o</code>. 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 <code>--mount-options=ro</code>; filesystem-only <code>--fs-options=ro</code> can still allow journal recovery writes.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><em>--hash=HASH</em></td>
|
||||
@@ -197,7 +197,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td><em>-m OPTION1[,OPTION2,...]</em> or <em>--mount-options=OPTION1[,OPTION2,...]</em></td>
|
||||
<td>Set VeraCrypt volume mount options. Supported options are <em>headerbak</em>, <em>nokernelcrypto</em>, <em>readonly</em> or <em>ro</em>, <em>system</em>, and <em>timestamp</em> or <em>ts</em>. On Linux, <em>kernelntfs</em> enables in-kernel NTFS driver selection for the current mount when NTFS is detected and no filesystem type was supplied.</td>
|
||||
<td>Set VeraCrypt volume mount options. Supported options are <em>headerbak</em>, <em>nokernelcrypto</em>, <em>readonly</em> or <em>ro</em>, <em>system</em>, and <em>timestamp</em> or <em>ts</em>. On Linux, <em>kernelntfs</em> 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.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><em>--new-hash=HASH</em></td>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user