From 54d93aef3bf08ab000811abdf4911d259b94953d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 2 Nov 2026 16:52:26 +0200 Subject: [PATCH 1/2] Feature: Re-lock LUKS volumes the app unlocked on unmount/eject Co-Authored-By: Claude Sonnet 5.5 --- .../Volumes/UDisks2Model.cs | 32 +++++++ .../Volumes/UDisks2VolumeService.cs | 24 ++++++ .../Volumes/FakeUDisks2.cs | 11 ++- .../Volumes/UDisks2LuksTests.cs | 83 +++++++++++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) diff --git a/src/Files.Platform.Linux/Volumes/UDisks2Model.cs b/src/Files.Platform.Linux/Volumes/UDisks2Model.cs index aba444bdb63f..d7e085c2de5b 100644 --- a/src/Files.Platform.Linux/Volumes/UDisks2Model.cs +++ b/src/Files.Platform.Linux/Volumes/UDisks2Model.cs @@ -246,6 +246,16 @@ public static IReadOnlyList BuildVolumes(UDisks2Objects objects) return null; } + /// Finds the encrypted container behind the cleartext device , or null if it is not a cleartext device. + public static string? FindCryptoBacking(UDisks2Objects objects, string cleartext) + { + if (objects.TryGetValue(cleartext, out var interfaces) && interfaces.TryGetValue(BlockInterface, out var block) && + String(block, "CryptoBackingDevice") is { } backing && IsBlockPath(backing) && objects.ContainsKey(backing)) + return backing; + + return null; + } + /// Whether belongs to the system rather than being a user-visible drive. public static bool IsPlumbingMountPoint(string mountPoint) { @@ -295,4 +305,26 @@ private static ulong UInt64(Dictionary properties, string key) private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value; } + + /// + /// Remembers the cleartext devices the app unlocked itself, so only those are locked again; volumes that were already unlocked are left alone. + /// + public sealed class UDisks2UnlockTracker + { + private readonly HashSet unlocked = new(StringComparer.Ordinal); + + /// Records that the app unlocked . + public void Track(string cleartextPath) + { + lock (unlocked) + unlocked.Add(cleartextPath); + } + + /// Forgets and returns whether the app had unlocked it. + public bool TryForget(string cleartextPath) + { + lock (unlocked) + return unlocked.Remove(cleartextPath); + } + } } diff --git a/src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs b/src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs index 5606c7c49e9f..11418ea4343d 100644 --- a/src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs +++ b/src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs @@ -5,6 +5,7 @@ using Files.Platform.Linux.DBus; using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Security.Cryptography; using System.Threading; @@ -33,6 +34,7 @@ public sealed class UDisks2VolumeService : IVolumeService private const int MaxPassphraseAttempts = 5; private readonly IVolumePassphrasePrompt? passphrasePrompt; + private readonly UDisks2UnlockTracker unlockTracker = new(); private readonly SemaphoreSlim connectGate = new(1, 1); private readonly object watchGate = new(); private DBusConnection? connection; @@ -166,6 +168,7 @@ public async Task UnmountAsync(string volumeId, CancellationToken cancellationTo try { await CallAsync(volumeId, UDisks2Parser.FilesystemInterface, "Unmount", static (Message _, object? _) => null, cancellationToken).ConfigureAwait(false); + await RelockAsync(volumeId, cancellationToken).ConfigureAwait(false); } catch (DBusErrorReplyException ex) when (ex.ErrorName == "org.freedesktop.UDisks2.Error.NotMounted") { @@ -176,6 +179,26 @@ public async Task UnmountAsync(string volumeId, CancellationToken cancellationTo } } + /// Locks the container behind if the app unlocked it; a failure is logged and never fails the unmount. + private async Task RelockAsync(string cleartextId, CancellationToken cancellationToken) + { + if (!unlockTracker.TryForget(cleartextId)) + return; + + try + { + var objects = await GetObjectsAsync(cancellationToken).ConfigureAwait(false); + if (UDisks2Parser.FindCryptoBacking(objects, cleartextId) is not { } container) + return; + + await CallAsync(container, UDisks2Parser.EncryptedInterface, "Lock", static (Message _, object? _) => null, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException && (IsBackendFailure(ex) || ex is VolumeOperationException)) + { + Debug.WriteLine($"Could not lock {cleartextId} after unmounting: {ex.Message}"); + } + } + /// public async Task EjectAsync(string volumeId, CancellationToken cancellationToken = default) { @@ -280,6 +303,7 @@ private async Task UnlockAsync(string volumeId, byte[] passphrase, Cance var result = await bus.CallTracedAsync("dbus.volumes", message, static (Message m, object? _) => m.GetBodyReader().ReadObjectPathAsString(), null) .WaitAsync(CallTimeout, cancellationToken).ConfigureAwait(false); await Task.Yield(); + unlockTracker.Track(result); return result; } catch (DBusErrorReplyException ex) when (IsAlreadyUnlocked(ex)) diff --git a/tests/Files.Platform.Tests/Volumes/FakeUDisks2.cs b/tests/Files.Platform.Tests/Volumes/FakeUDisks2.cs index 251408626015..ea6b9fc314ee 100644 --- a/tests/Files.Platform.Tests/Volumes/FakeUDisks2.cs +++ b/tests/Files.Platform.Tests/Volumes/FakeUDisks2.cs @@ -33,7 +33,7 @@ internal sealed class FakeUDisks2 : IPathMethodHandler, IDisposable public List Calls { get; } = new(); - /// Errors to answer with, keyed by Interface.Member. + /// Errors to answer with, keyed by Interface.Member (org.freedesktop.UDisks2.Encrypted.Lock makes Lock fail). public Dictionary Errors { get; } = new(); /// Messages for , keyed the same way (default "fake failure"). @@ -275,6 +275,15 @@ public ValueTask HandleMethodAsync(MethodContext context) if (iface == UDisks2Parser.FilesystemInterface && member == "Unmount" && block is not null) SetMounts(block); + if (iface == UDisks2Parser.EncryptedInterface && member == "Lock" && block is not null) + { + lock (gate) + { + Objects.Remove(Root + "/block_devices/dm_" + block); + Objects[path2][UDisks2Parser.EncryptedInterface]["CleartextDevice"] = new ObjPath("/"); + } + } + using var empty = context.CreateReplyWriter(""); context.Reply(empty.CreateMessage()); return ValueTask.CompletedTask; diff --git a/tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs b/tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs index 10c8510cf2be..ddff1d467221 100644 --- a/tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs +++ b/tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs @@ -222,6 +222,89 @@ public async Task WrongPassphraseStopsPromptingAfterTheRetryLimit() } } + private const string LockMember = "org.freedesktop.UDisks2.Encrypted.Lock"; + + private static List Members(FakeUDisks2 fake) => fake.CallsSnapshot().Select(c => c.Interface.Split('.').Last() + "." + c.Member).ToList(); + + [TestMethod] + public async Task UnmountLocksTheContainerTheAppUnlocked() + { + var (bus, fake) = await StartAsync(); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, new ScriptedPrompt("correct horse"))) + { + await service.MountAsync(Locked); + await service.UnmountAsync(Cleartext); + + CollectionAssert.AreEqual(new[] { "Encrypted.Unlock", "Filesystem.Mount", "Filesystem.Unmount", "Encrypted.Lock" }, Members(fake).ToArray()); + Assert.AreEqual(Locked, fake.CallsSnapshot()[3].Path); + Assert.IsTrue((await service.GetVolumesAsync()).Single().IsLocked); + + // The entry is forgotten: after an external unlock, unmounting does not lock again + fake.AddCleartext("sde1"); + await service.UnmountAsync(Cleartext); + Assert.AreEqual(1, Members(fake).Count(m => m == "Encrypted.Lock")); + } + } + + [TestMethod] + public async Task EjectLocksTheContainerTheAppUnlocked() + { + var (bus, fake) = await StartAsync(); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, new ScriptedPrompt("correct horse"))) + { + await service.MountAsync(Locked); + await service.EjectAsync(Cleartext); + + var members = Members(fake); + Assert.IsTrue(members.IndexOf("Filesystem.Unmount") < members.IndexOf("Encrypted.Lock")); + Assert.AreEqual(Locked, fake.CallsSnapshot().Single(c => c.Member == "Lock").Path); + } + } + + [TestMethod] + public async Task UnmountDoesNotLockAContainerThatWasAlreadyUnlocked() + { + var (bus, fake) = await StartAsync(); + fake.AddCleartext("sde1"); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, new ScriptedPrompt("correct horse"))) + { + await service.MountAsync(Locked); + await service.UnmountAsync(Cleartext); + + CollectionAssert.AreEqual(new[] { "Filesystem.Mount", "Filesystem.Unmount" }, Members(fake).ToArray()); + } + } + + [TestMethod] + public async Task LockFailureDoesNotFailTheUnmount() + { + var (bus, fake) = await StartAsync(); + fake.Errors[LockMember] = "org.freedesktop.UDisks2.Error.DeviceBusy"; + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, new ScriptedPrompt("correct horse"))) + { + await service.MountAsync(Locked); + await service.UnmountAsync(Cleartext); + + Assert.AreEqual(1, Members(fake).Count(m => m == "Encrypted.Lock")); + Assert.IsFalse((await service.GetVolumesAsync()).Single().IsMounted); + } + } + + [TestMethod] + public async Task FailedUnmountDoesNotLock() + { + var (bus, fake) = await StartAsync(); + fake.Errors["org.freedesktop.UDisks2.Filesystem.Unmount"] = "org.freedesktop.UDisks2.Error.DeviceBusy"; + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, new ScriptedPrompt("correct horse"))) + { + await service.MountAsync(Locked); + var ex = await Assert.ThrowsExactlyAsync(() => service.UnmountAsync(Cleartext)); + + Assert.AreEqual(VolumeError.Busy, ex.Error); + Assert.IsFalse(Members(fake).Contains("Encrypted.Lock")); + } + } + [TestMethod] public void TranslateMapsOnlyPassphraseFailuresToIncorrectPassphrase() { From 3b1d3fc8f1c921ee1cec8332dd787343e9a86703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 2 Nov 2026 16:55:47 +0200 Subject: [PATCH 2/2] Fix LUKS eject skipping drive eject and relock edge cases The cleartext device has no drive of its own, so ejecting it only unmounted and locked but never ejected or powered off the drive. Cleartext devices now use their container's drive. Also relock when the volume was already unmounted (including on eject), and only lock the container the app unlocked. Co-Authored-By: Claude Sonnet 5.5 --- .../Volumes/UDisks2Model.cs | 30 ++++++-- .../Volumes/UDisks2VolumeService.cs | 17 +++-- .../Volumes/UDisks2LuksTests.cs | 74 +++++++++++++++++++ 3 files changed, 108 insertions(+), 13 deletions(-) diff --git a/src/Files.Platform.Linux/Volumes/UDisks2Model.cs b/src/Files.Platform.Linux/Volumes/UDisks2Model.cs index d7e085c2de5b..7bc361b0253d 100644 --- a/src/Files.Platform.Linux/Volumes/UDisks2Model.cs +++ b/src/Files.Platform.Linux/Volumes/UDisks2Model.cs @@ -183,7 +183,7 @@ public static IReadOnlyList BuildVolumes(UDisks2Objects objects) var optical = false; var canEject = false; var canPowerOff = false; - if (block.TryGetValue("Drive", out var driveValue) && driveValue is string drivePath && drivePath != "/" && + if (DrivePathOf(objects, block) is { } drivePath && objects.TryGetValue(drivePath, out var driveInterfaces) && driveInterfaces.TryGetValue(DriveInterface, out var drive)) { driveId = drivePath; @@ -246,6 +246,20 @@ public static IReadOnlyList BuildVolumes(UDisks2Objects objects) return null; } + /// The drive of a block device; a cleartext device has none of its own, so it uses its container's. + private static string? DrivePathOf(UDisks2Objects objects, Dictionary block) + { + if (block.TryGetValue("Drive", out var value) && value is string drive && drive != "/") + return drive; + + if (String(block, "CryptoBackingDevice") is { } backing && objects.TryGetValue(backing, out var backingInterfaces) && + backingInterfaces.TryGetValue(BlockInterface, out var backingBlock) && + backingBlock.TryGetValue("Drive", out value) && value is string backingDrive && backingDrive != "/") + return backingDrive; + + return null; + } + /// Finds the encrypted container behind the cleartext device , or null if it is not a cleartext device. public static string? FindCryptoBacking(UDisks2Objects objects, string cleartext) { @@ -311,20 +325,20 @@ private static ulong UInt64(Dictionary properties, string key) /// public sealed class UDisks2UnlockTracker { - private readonly HashSet unlocked = new(StringComparer.Ordinal); + private readonly Dictionary unlocked = new(StringComparer.Ordinal); - /// Records that the app unlocked . - public void Track(string cleartextPath) + /// Records that the app unlocked as . + public void Track(string cleartextPath, string containerPath) { lock (unlocked) - unlocked.Add(cleartextPath); + unlocked[cleartextPath] = containerPath; } - /// Forgets and returns whether the app had unlocked it. - public bool TryForget(string cleartextPath) + /// Forgets and returns the container the app unlocked behind it, or null if it did not. + public string? TryForget(string cleartextPath) { lock (unlocked) - return unlocked.Remove(cleartextPath); + return unlocked.Remove(cleartextPath, out var container) ? container : null; } } } diff --git a/src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs b/src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs index 11418ea4343d..298993922ecc 100644 --- a/src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs +++ b/src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs @@ -172,6 +172,7 @@ public async Task UnmountAsync(string volumeId, CancellationToken cancellationTo } catch (DBusErrorReplyException ex) when (ex.ErrorName == "org.freedesktop.UDisks2.Error.NotMounted") { + await RelockAsync(volumeId, cancellationToken).ConfigureAwait(false); } catch (DBusErrorReplyException ex) { @@ -182,13 +183,14 @@ public async Task UnmountAsync(string volumeId, CancellationToken cancellationTo /// Locks the container behind if the app unlocked it; a failure is logged and never fails the unmount. private async Task RelockAsync(string cleartextId, CancellationToken cancellationToken) { - if (!unlockTracker.TryForget(cleartextId)) + if (unlockTracker.TryForget(cleartextId) is not { } tracked) return; try { var objects = await GetObjectsAsync(cancellationToken).ConfigureAwait(false); - if (UDisks2Parser.FindCryptoBacking(objects, cleartextId) is not { } container) + // A reused device name may now belong to a volume unlocked elsewhere + if (UDisks2Parser.FindCryptoBacking(objects, cleartextId) is not { } container || container != tracked) return; await CallAsync(container, UDisks2Parser.EncryptedInterface, "Lock", static (Message _, object? _) => null, cancellationToken).ConfigureAwait(false); @@ -303,7 +305,7 @@ private async Task UnlockAsync(string volumeId, byte[] passphrase, Cance var result = await bus.CallTracedAsync("dbus.volumes", message, static (Message m, object? _) => m.GetBodyReader().ReadObjectPathAsString(), null) .WaitAsync(CallTimeout, cancellationToken).ConfigureAwait(false); await Task.Yield(); - unlockTracker.Track(result); + unlockTracker.Track(result, volumeId); return result; } catch (DBusErrorReplyException ex) when (IsAlreadyUnlocked(ex)) @@ -335,8 +337,13 @@ private static bool IsAlreadyUnlocked(DBusErrorReplyException ex) var siblings = volume.DriveId is null ? [volume] : volumes.Where(v => v.DriveId == volume.DriveId).ToList(); - foreach (var sibling in siblings.Where(v => v.IsMounted)) - await UnmountAsync(sibling.Id, cancellationToken).ConfigureAwait(false); + foreach (var sibling in siblings) + { + if (sibling.IsMounted) + await UnmountAsync(sibling.Id, cancellationToken).ConfigureAwait(false); + else + await RelockAsync(sibling.Id, cancellationToken).ConfigureAwait(false); + } return (volume.DriveId, volume.CanEject, volume.CanPowerOff); } diff --git a/tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs b/tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs index ddff1d467221..fa77681c2cff 100644 --- a/tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs +++ b/tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs @@ -258,6 +258,80 @@ public async Task EjectLocksTheContainerTheAppUnlocked() var members = Members(fake); Assert.IsTrue(members.IndexOf("Filesystem.Unmount") < members.IndexOf("Encrypted.Lock")); Assert.AreEqual(Locked, fake.CallsSnapshot().Single(c => c.Member == "Lock").Path); + + // The drive is ejected after the container is locked, though the cleartext device has no drive of its own + Assert.IsTrue(members.IndexOf("Encrypted.Lock") < members.IndexOf("Drive.Eject")); + } + } + + [TestMethod] + public async Task EjectLocksAnUnmountedContainerTheAppUnlockedBeforeEjecting() + { + var (bus, fake) = await StartAsync(); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, new ScriptedPrompt("correct horse"))) + { + await service.MountAsync(Locked); + fake.SetMounts("dm_sde1"); + await service.EjectAsync(Cleartext); + + var members = Members(fake); + Assert.IsFalse(members.Contains("Filesystem.Unmount")); + Assert.IsTrue(members.IndexOf("Encrypted.Lock") < members.IndexOf("Drive.Eject")); + } + } + + [TestMethod] + public async Task UnmountOfAnAlreadyUnmountedVolumeStillLocksWhatTheAppUnlocked() + { + var (bus, fake) = await StartAsync(); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, new ScriptedPrompt("correct horse"))) + { + await service.MountAsync(Locked); + fake.Errors["org.freedesktop.UDisks2.Filesystem.Unmount"] = "org.freedesktop.UDisks2.Error.NotMounted"; + await service.UnmountAsync(Cleartext); + + Assert.AreEqual(1, Members(fake).Count(m => m == "Encrypted.Lock")); + } + } + + [TestMethod] + public async Task CleartextDeviceUsesTheDriveOfItsContainer() + { + var (bus, fake) = await StartAsync(); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address)) + { + fake.AddCleartext("sde1"); + var volume = (await service.GetVolumesAsync()).Single(); + + Assert.AreEqual("/org/freedesktop/UDisks2/drives/Generic_Flash", volume.DriveId); + Assert.IsTrue(volume.CanEject); + } + } + + [TestMethod] + public void UnlockTrackerOnlyForgetsTheContainerItTracked() + { + var tracker = new UDisks2UnlockTracker(); + Assert.IsNull(tracker.TryForget(Cleartext)); + + tracker.Track(Cleartext, Locked); + Assert.AreEqual(Locked, tracker.TryForget(Cleartext)); + Assert.IsNull(tracker.TryForget(Cleartext)); + } + + [TestMethod] + public async Task ReusedCleartextNameForAnotherContainerIsNotLocked() + { + var (bus, fake) = await StartAsync(); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, new ScriptedPrompt("correct horse"))) + { + await service.MountAsync(Locked); + fake.AddEncrypted("sdf1", "Generic_Flash", "other"); + fake.SetMounts("dm_sde1"); + fake.Objects["/org/freedesktop/UDisks2/block_devices/dm_sde1"][UDisks2Parser.BlockInterface]["CryptoBackingDevice"] = new ObjPath("/org/freedesktop/UDisks2/block_devices/sdf1"); + await service.UnmountAsync(Cleartext); + + Assert.IsFalse(Members(fake).Contains("Encrypted.Lock")); } }