From cfa18e37a4a1cc9c8cbef111a8f177b1f8c9c6df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 2 Nov 2026 16:05:18 +0200 Subject: [PATCH 1/2] Unlock LUKS-encrypted volumes with a passphrase prompt Locked LUKS containers are listed and, when mounted, unlocked through UDisks2 Encrypted.Unlock using a passphrase prompt provided by the UI layer. Already-unlocked containers mount their existing cleartext device. Co-Authored-By: Claude Fable 5.1 --- .../Dialogs/CredentialDialog.xaml.cs | 6 + .../Helpers/Application/AppLifecycleHelper.cs | 1 + src/Files.App/Strings/en-US/Resources.resw | 6 + .../Utils/Global/VolumePassphrasePrompt.cs | 39 ++++ .../Storage/Helpers/DriveHelpers.Linux.cs | 5 + .../Dialogs/CredentialDialogViewModel.cs | 10 + .../Volumes/IVolumeService.cs | 28 ++- .../Volumes/UDisks2Model.cs | 38 ++- .../Volumes/UDisks2VolumeService.cs | 101 +++++++- .../VolumesServiceCollectionExtensions.cs | 2 +- .../Volumes/FakeUDisks2.cs | 96 ++++++++ .../Volumes/UDisks2LuksTests.cs | 220 ++++++++++++++++++ 12 files changed, 545 insertions(+), 7 deletions(-) create mode 100644 src/Files.App/Utils/Global/VolumePassphrasePrompt.cs create mode 100644 tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs diff --git a/src/Files.App/Dialogs/CredentialDialog.xaml.cs b/src/Files.App/Dialogs/CredentialDialog.xaml.cs index a8b5b3bdf752..3de1dfe2052d 100644 --- a/src/Files.App/Dialogs/CredentialDialog.xaml.cs +++ b/src/Files.App/Dialogs/CredentialDialog.xaml.cs @@ -34,6 +34,12 @@ public CredentialDialog() private void ContentDialog_Opened(ContentDialog sender, ContentDialogOpenedEventArgs args) { + if (ViewModel.Title is { } title) + Title = title; + + if (ViewModel.Description is { } description) + Description.Text = description; + // Focus the first editable input so the user can type without clicking first var target = UserName is { Visibility: Visibility.Visible, IsEnabled: true } ? UserName : (Control)Password; target.Focus(FocusState.Programmatic); diff --git a/src/Files.App/Helpers/Application/AppLifecycleHelper.cs b/src/Files.App/Helpers/Application/AppLifecycleHelper.cs index 9cd693844b82..c5c465ade654 100644 --- a/src/Files.App/Helpers/Application/AppLifecycleHelper.cs +++ b/src/Files.App/Helpers/Application/AppLifecycleHelper.cs @@ -403,6 +403,7 @@ public static IServiceProvider ConfigureHost(AppModel appModel) services.AddFtpStorables(); Files.App.Storage.Archives.ArchiveServiceCollectionExtensions.AddArchiveStorables(services); services.AddSingleton(); + services.AddSingleton(); // Conditional DI services.AddSingleton(); diff --git a/src/Files.App/Strings/en-US/Resources.resw b/src/Files.App/Strings/en-US/Resources.resw index cced72f02c60..fefbaff6a868 100644 --- a/src/Files.App/Strings/en-US/Resources.resw +++ b/src/Files.App/Strings/en-US/Resources.resw @@ -4907,6 +4907,12 @@ You are not authorized to do this. + + Unlock encrypted volume + + + Enter the passphrase to unlock {0}: + This drive is not mounted. diff --git a/src/Files.App/Utils/Global/VolumePassphrasePrompt.cs b/src/Files.App/Utils/Global/VolumePassphrasePrompt.cs new file mode 100644 index 000000000000..dddb7a3cc3ed --- /dev/null +++ b/src/Files.App/Utils/Global/VolumePassphrasePrompt.cs @@ -0,0 +1,39 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Volumes; +using VolumeInfo = Files.Platform.Abstractions.Volumes.VolumeInfo; + +namespace Files.App.Utils +{ + /// + /// Asks for the passphrase of a locked encrypted volume with the credential dialog. + /// + internal sealed class VolumePassphrasePrompt : IVolumePassphrasePrompt + { + public async Task RequestPassphraseAsync(VolumeInfo volume, bool retry, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var viewModel = new CredentialDialogViewModel + { + PasswordOnly = true, + CanBeAnonymous = false, + IsWrongPassword = retry, + Title = Strings.LinuxVolumeUnlockTitle.GetLocalizedResource(), + Description = string.Format(Strings.LinuxVolumeUnlockDescription.GetLocalizedResource(), string.IsNullOrEmpty(volume.Label) ? volume.Device : volume.Label), + }; + + var dialogService = Ioc.Default.GetRequiredService(); + var result = await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => dialogService.ShowDialogAsync(viewModel)); + cancellationToken.ThrowIfCancellationRequested(); + + if (result != DialogResult.Primary || viewModel.Password is not { } password) + return null; + + // The dialog's buffer is cleared on dispose, so hand out a copy the service clears itself + using (password) + return (byte[])password.Bytes.Clone(); + } + } +} diff --git a/src/Files.App/Utils/Storage/Helpers/DriveHelpers.Linux.cs b/src/Files.App/Utils/Storage/Helpers/DriveHelpers.Linux.cs index b879384a5e72..8b4ca58824e0 100644 --- a/src/Files.App/Utils/Storage/Helpers/DriveHelpers.Linux.cs +++ b/src/Files.App/Utils/Storage/Helpers/DriveHelpers.Linux.cs @@ -320,6 +320,11 @@ public static async Task UnmountVolumeAsync(string path) return volume.MountPoint ?? await volumes.MountAsync(volume.Id); } + catch (OperationCanceledException) + { + // The user dismissed the passphrase prompt + return null; + } catch (VolumeOperationException ex) { await ShowVolumeErrorAsync(ex); diff --git a/src/Files.App/ViewModels/Dialogs/CredentialDialogViewModel.cs b/src/Files.App/ViewModels/Dialogs/CredentialDialogViewModel.cs index 1108a2baf36c..a2cc0c15496d 100644 --- a/src/Files.App/ViewModels/Dialogs/CredentialDialogViewModel.cs +++ b/src/Files.App/ViewModels/Dialogs/CredentialDialogViewModel.cs @@ -12,6 +12,16 @@ public string? UserName set => SetProperty(ref _UserName, value); } + /// + /// Overrides the default dialog title when set. + /// + public string? Title { get; set; } + + /// + /// Overrides the default description when set. + /// + public string? Description { get; set; } + private bool _IsAnonymous; public bool IsAnonymous { diff --git a/src/Files.Platform.Abstractions/Volumes/IVolumeService.cs b/src/Files.Platform.Abstractions/Volumes/IVolumeService.cs index 642a8e4d3e19..5b93b492dfda 100644 --- a/src/Files.Platform.Abstractions/Volumes/IVolumeService.cs +++ b/src/Files.Platform.Abstractions/Volumes/IVolumeService.cs @@ -24,6 +24,7 @@ namespace Files.Platform.Abstractions.Volumes /// The identifier of the drive the volume lives on, if known. /// The drive can be ejected. /// The drive can be powered off. + /// The volume is an encrypted (LUKS) container that has to be unlocked with a passphrase before it can be mounted. public sealed record VolumeInfo( string Id, string Device, @@ -37,7 +38,8 @@ public sealed record VolumeInfo( IReadOnlyList MountPoints, string? DriveId = null, bool CanEject = false, - bool CanPowerOff = false) + bool CanPowerOff = false, + bool IsLocked = false) { /// Gets whether the volume is mounted. public bool IsMounted => MountPoints.Count > 0; @@ -99,6 +101,9 @@ public enum VolumeError /// The volume does not exist (any more). NotFound, + + /// The passphrase did not unlock the encrypted volume. + IncorrectPassphrase, } /// @@ -116,6 +121,21 @@ public VolumeOperationException(VolumeError error, string message, Exception? in public VolumeError Error { get; } } + /// + /// Asks the user for the passphrase of an encrypted volume. Implemented by the UI layer. + /// + public interface IVolumePassphrasePrompt + { + /// + /// Requests the passphrase of as UTF-8 bytes, or null if the user cancelled. + /// The caller clears the returned array once it has been used; it is never persisted. + /// + /// The locked volume. + /// The previous passphrase was wrong. + /// Cancels the prompt. + Task RequestPassphraseAsync(VolumeInfo volume, bool retry, CancellationToken cancellationToken = default); + } + /// /// Lists, mounts and unmounts volumes and reports when they come and go. /// @@ -136,8 +156,12 @@ public interface IVolumeService : IDisposable /// Lists the volumes (mounted or not), excluding ones the backend hides. Task> GetVolumesAsync(CancellationToken cancellationToken = default); - /// Mounts the volume (polkit may prompt) and returns the mount point. + /// + /// Mounts the volume (polkit may prompt) and returns the mount point. + /// A locked encrypted volume is unlocked first, asking for the passphrase through . + /// /// The operation failed. + /// The user cancelled the passphrase prompt. Task MountAsync(string volumeId, CancellationToken cancellationToken = default); /// Unmounts the volume. diff --git a/src/Files.Platform.Linux/Volumes/UDisks2Model.cs b/src/Files.Platform.Linux/Volumes/UDisks2Model.cs index c3cca9fade3f..aba444bdb63f 100644 --- a/src/Files.Platform.Linux/Volumes/UDisks2Model.cs +++ b/src/Files.Platform.Linux/Volumes/UDisks2Model.cs @@ -132,6 +132,9 @@ public static class UDisks2Parser /// Interface names. public const string LoopInterface = "org.freedesktop.UDisks2.Loop"; + /// Interface names. + public const string EncryptedInterface = "org.freedesktop.UDisks2.Encrypted"; + /// Whether looks like a UDisks2 block device object path (no further interpretation of the string is done by callers). public static bool IsBlockPath(string? id) { @@ -156,8 +159,15 @@ public static IReadOnlyList BuildVolumes(UDisks2Objects objects) if (!path.StartsWith(BlockPrefix, StringComparison.Ordinal)) continue; - // LINUX-TODO(udisks2-luks): encrypted volumes (org.freedesktop.UDisks2.Encrypted) are not offered; unlocking needs a passphrase prompt - if (!interfaces.TryGetValue(BlockInterface, out var block) || !interfaces.TryGetValue(FilesystemInterface, out var fs)) + if (!interfaces.TryGetValue(BlockInterface, out var block)) + continue; + + // An encrypted container is offered only while locked; once unlocked its cleartext device is the volume + var locked = interfaces.ContainsKey(EncryptedInterface) && FindCleartext(objects, path) is null; + Dictionary? fs; + if (locked) + fs = []; + else if (!interfaces.TryGetValue(FilesystemInterface, out fs)) continue; if (Bool(block, "HintIgnore")) @@ -207,13 +217,35 @@ public static IReadOnlyList BuildVolumes(UDisks2Objects objects) mountPoints, driveId, canEject, - canPowerOff)); + canPowerOff, + locked)); } result.Sort(static (a, b) => string.CompareOrdinal(a.Device, b.Device)); return result; } + /// + /// Finds the unlocked cleartext device of the encrypted block device , or null if it is locked (or not encrypted). + /// + public static string? FindCleartext(UDisks2Objects objects, string path) + { + if (!objects.TryGetValue(path, out var interfaces) || !interfaces.TryGetValue(EncryptedInterface, out var encrypted)) + return null; + + if (String(encrypted, "CleartextDevice") is { } cleartext && IsBlockPath(cleartext) && objects.ContainsKey(cleartext)) + return cleartext; + + // CleartextDevice can lag behind; the cleartext device always points back at its container + foreach (var (other, otherInterfaces) in objects) + { + if (IsBlockPath(other) && otherInterfaces.TryGetValue(BlockInterface, out var block) && String(block, "CryptoBackingDevice") == path) + return other; + } + + return null; + } + /// Whether belongs to the system rather than being a user-visible drive. public static bool IsPlumbingMountPoint(string mountPoint) { diff --git a/src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs b/src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs index fd077416c2f1..5f350b119055 100644 --- a/src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs +++ b/src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using Tmds.DBus.Protocol; @@ -29,6 +30,7 @@ public sealed class UDisks2VolumeService : IVolumeService private static readonly TimeSpan RefreshDelay = TimeSpan.FromMilliseconds(150); private readonly string? busAddress; + private readonly IVolumePassphrasePrompt? passphrasePrompt; private readonly SemaphoreSlim connectGate = new(1, 1); private readonly object watchGate = new(); private DBusConnection? connection; @@ -40,10 +42,12 @@ public sealed class UDisks2VolumeService : IVolumeService /// /// Creates the service. null means the system bus; tests pass a private bus. + /// is asked for the passphrase of locked encrypted volumes; without it they cannot be mounted. /// - public UDisks2VolumeService(string? busAddress = null) + public UDisks2VolumeService(string? busAddress = null, IVolumePassphrasePrompt? passphrasePrompt = null) { this.busAddress = busAddress; + this.passphrasePrompt = passphrasePrompt; } /// @@ -135,6 +139,7 @@ public void StopWatching() public async Task MountAsync(string volumeId, CancellationToken cancellationToken = default) { RequireBlockPath(volumeId); + volumeId = await ResolveCleartextAsync(volumeId, cancellationToken).ConfigureAwait(false); try { // auth.no_user_interaction=false lets polkit ask the user for permission when the action requires it @@ -205,6 +210,92 @@ public void Dispose() connectGate.Dispose(); } + /// Returns the volume to mount: the volume itself, or for an encrypted container its cleartext device (unlocking it first if needed). + private async Task ResolveCleartextAsync(string volumeId, CancellationToken cancellationToken) + { + UDisks2Objects objects; + try + { + objects = await GetObjectsAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (IsBackendFailure(ex)) + { + // The mount call below reports the unavailable backend + return volumeId; + } + + if (!objects.TryGetValue(volumeId, out var interfaces) || !interfaces.ContainsKey(UDisks2Parser.EncryptedInterface)) + return volumeId; + + if (UDisks2Parser.FindCleartext(objects, volumeId) is { } unlocked) + return unlocked; + + var volume = UDisks2Parser.BuildVolumes(objects).FirstOrDefault(v => v.Id == volumeId) + ?? throw new VolumeOperationException(VolumeError.NotFound, "The volume does not exist."); + var prompt = passphrasePrompt + ?? throw new VolumeOperationException(VolumeError.NotSupported, "Unlocking encrypted volumes is not available."); + + var retry = false; + while (true) + { + var passphrase = await prompt.RequestPassphraseAsync(volume, retry, cancellationToken).ConfigureAwait(false); + if (passphrase is null) + throw new OperationCanceledException("The passphrase prompt was cancelled."); + + try + { + return await UnlockAsync(volumeId, passphrase, cancellationToken).ConfigureAwait(false); + } + catch (VolumeOperationException ex) when (ex.Error == VolumeError.IncorrectPassphrase) + { + retry = true; + } + finally + { + CryptographicOperations.ZeroMemory(passphrase); + } + } + } + + private async Task UnlockAsync(string volumeId, byte[] passphrase, CancellationToken cancellationToken) + { + var bus = await GetConnectionAsync().ConfigureAwait(false) + ?? throw new VolumeOperationException(VolumeError.Failed, "UDisks2 is not available."); + + MessageBuffer message; + using (var writer = bus.GetMessageWriter()) + { + writer.WriteMethodCallHeader(ServiceName, volumeId, UDisks2Parser.EncryptedInterface, "Unlock", "sa{sv}"); + writer.WriteString(passphrase); + writer.WriteDictionary(new Dictionary { ["auth.no_user_interaction"] = false }); + message = writer.CreateMessage(); + } + + try + { + var result = await bus.CallTracedAsync("dbus.volumes", message, static (Message m, object? _) => m.GetBodyReader().ReadObjectPathAsString(), null) + .WaitAsync(CallTimeout, cancellationToken).ConfigureAwait(false); + await Task.Yield(); + return result; + } + catch (DBusErrorReplyException ex) when (IsAlreadyUnlocked(ex)) + { + var objects = await GetObjectsAsync(cancellationToken).ConfigureAwait(false); + return UDisks2Parser.FindCleartext(objects, volumeId) ?? throw Translate(ex); + } + catch (DBusErrorReplyException ex) + { + throw Translate(ex); + } + catch (TimeoutException ex) + { + throw new VolumeOperationException(VolumeError.Failed, "UDisks2 did not answer.", ex); + } + } + + private static bool IsAlreadyUnlocked(DBusErrorReplyException ex) + => ex.ErrorMessage?.Contains("already unlocked", StringComparison.OrdinalIgnoreCase) == true; + private async Task<(string? Drive, bool CanEject, bool CanPowerOff)> UnmountDriveAsync(string volumeId, CancellationToken cancellationToken) { RequireBlockPath(volumeId); @@ -246,8 +337,11 @@ private static void RequireBlockPath(string volumeId) public static VolumeOperationException Translate(DBusErrorReplyException ex) { var name = ex.ErrorName ?? ""; + var message = ex.ErrorMessage ?? ""; var error = name switch { + // UDisks2 reports a wrong passphrase as a generic Failed error; libblockdev/cryptsetup words it as EPERM or "passphrase" + _ when name.EndsWith("Error.Failed", StringComparison.Ordinal) && IsPassphraseFailure(message) => VolumeError.IncorrectPassphrase, _ when name.Contains("NotAuthorized", StringComparison.Ordinal) => VolumeError.NotAuthorized, _ when name.EndsWith("DeviceBusy", StringComparison.Ordinal) => VolumeError.Busy, _ when name.EndsWith("NotSupported", StringComparison.Ordinal) => VolumeError.NotSupported, @@ -258,6 +352,11 @@ _ when name.EndsWith("UnknownObject", StringComparison.Ordinal) || name.EndsWith return new VolumeOperationException(error, ex.ErrorMessage ?? name, ex); } + private static bool IsPassphraseFailure(string message) + => message.Contains("Operation not permitted", StringComparison.OrdinalIgnoreCase) + || message.Contains("passphrase", StringComparison.OrdinalIgnoreCase) + || message.Contains("No key available", StringComparison.OrdinalIgnoreCase); + private async Task CallAsync(string path, string iface, string member, MessageValueReader reader, CancellationToken cancellationToken) { var bus = await GetConnectionAsync().ConfigureAwait(false) diff --git a/src/Files.Platform.Linux/Volumes/VolumesServiceCollectionExtensions.cs b/src/Files.Platform.Linux/Volumes/VolumesServiceCollectionExtensions.cs index f642176cc613..6912f75a9eac 100644 --- a/src/Files.Platform.Linux/Volumes/VolumesServiceCollectionExtensions.cs +++ b/src/Files.Platform.Linux/Volumes/VolumesServiceCollectionExtensions.cs @@ -21,7 +21,7 @@ public static class VolumesServiceCollectionExtensions public static IServiceCollection AddLinuxVolumes(this IServiceCollection services) { services.TryAddSingleton(); - services.TryAddSingleton(_ => new UDisks2VolumeService()); + services.TryAddSingleton(sp => new UDisks2VolumeService(null, sp.GetService())); services.TryAddSingleton(); services.TryAddSingleton(); return services; diff --git a/tests/Files.Platform.Tests/Volumes/FakeUDisks2.cs b/tests/Files.Platform.Tests/Volumes/FakeUDisks2.cs index d1de77865095..ab29a7d74a33 100644 --- a/tests/Files.Platform.Tests/Volumes/FakeUDisks2.cs +++ b/tests/Files.Platform.Tests/Volumes/FakeUDisks2.cs @@ -36,6 +36,12 @@ internal sealed class FakeUDisks2 : IPathMethodHandler, IDisposable /// Errors to answer with, keyed by Interface.Member. public Dictionary Errors { get; } = new(); + /// Block device name to the passphrase that unlocks it (). + public Dictionary Passphrases { get; } = new(); + + /// The passphrases received by Unlock, in order. + public List ReceivedPassphrases { get; } = new(); + public string Path => Root; public bool HandlesChildPaths => true; @@ -106,6 +112,59 @@ public void AddBlock(string name, string? drive, string? label, string type, ulo Objects[Root + "/block_devices/" + name] = interfaces; } + /// Adds a locked LUKS container (no file system until unlocked; Unlock creates the cleartext device). + public void AddEncrypted(string name, string? drive, string passphrase, bool removable = true) + { + lock (gate) + { + Passphrases[name] = passphrase; + Objects[Root + "/block_devices/" + name] = new() + { + [UDisks2Parser.BlockInterface] = new() + { + ["Device"] = Bytes("/dev/" + name), + ["IdLabel"] = "", + ["IdType"] = "crypto_LUKS", + ["Size"] = 2_000_000_000UL, + ["Drive"] = new ObjPath(drive is null ? "/" : Root + "/drives/" + drive), + ["HintSystem"] = !removable, + ["HintIgnore"] = false, + }, + [UDisks2Parser.EncryptedInterface] = new() + { + ["CleartextDevice"] = new ObjPath("/"), + }, + }; + } + } + + /// Creates the cleartext device of an encrypted container as UDisks2 does after a successful unlock. + public string AddCleartext(string name) + { + var clear = "dm_" + name; + lock (gate) + { + Objects[Root + "/block_devices/" + clear] = new() + { + [UDisks2Parser.BlockInterface] = new() + { + ["Device"] = Bytes("/dev/" + clear), + ["IdLabel"] = "SECRET", + ["IdType"] = "ext4", + ["Size"] = 1_900_000_000UL, + ["Drive"] = new ObjPath("/"), + ["HintSystem"] = false, + ["HintIgnore"] = false, + ["CryptoBackingDevice"] = new ObjPath(Root + "/block_devices/" + name), + }, + [UDisks2Parser.FilesystemInterface] = new() { ["MountPoints"] = Array.Empty() }, + }; + Objects[Root + "/block_devices/" + name][UDisks2Parser.EncryptedInterface]["CleartextDevice"] = new ObjPath(Root + "/block_devices/" + clear); + } + + return Root + "/block_devices/" + clear; + } + public void SetMounts(string block, params string[] mounts) { lock (gate) @@ -184,6 +243,9 @@ public ValueTask HandleMethodAsync(MethodContext context) } var reader = request.GetBodyReader(); + if (iface == UDisks2Parser.EncryptedInterface && member == "Unlock") + return HandleUnlock(context, ref reader); + var raw = reader.ReadDictionaryOfStringToVariantValue(); var options = raw.ToDictionary(kv => kv.Key, kv => UDisks2Objects.ToClr(kv.Value)); lock (gate) @@ -215,6 +277,40 @@ public ValueTask HandleMethodAsync(MethodContext context) return ValueTask.CompletedTask; } + private ValueTask HandleUnlock(MethodContext context, ref Reader reader) + { + var passphrase = reader.ReadString(); + var options = reader.ReadDictionaryOfStringToVariantValue().ToDictionary(kv => kv.Key, kv => UDisks2Objects.ToClr(kv.Value)); + var path = context.Request.PathAsString ?? ""; + var block = path[UDisks2Parser.BlockPrefix.Length..]; + string? cleartext = null; + bool ok; + lock (gate) + { + Calls.Add(new FakeCall(path, UDisks2Parser.EncryptedInterface, "Unlock", options)); + ReceivedPassphrases.Add(passphrase); + ok = Passphrases.TryGetValue(block, out var expected) && expected == passphrase; + } + + if (Errors.TryGetValue(UDisks2Parser.EncryptedInterface + ".Unlock", out var error)) + { + context.ReplyError(error, "fake failure"); + return ValueTask.CompletedTask; + } + + if (!ok) + { + context.ReplyError("org.freedesktop.UDisks2.Error.Failed", "Error unlocking /dev/" + block + ": Failed to activate device: Operation not permitted"); + return ValueTask.CompletedTask; + } + + cleartext = AddCleartext(block); + using var writer = context.CreateReplyWriter("o"); + writer.WriteObjectPath(cleartext); + context.Reply(writer.CreateMessage()); + return ValueTask.CompletedTask; + } + private static void WriteInterfaces(ref MessageWriter writer, Dictionary> interfaces) { var start = writer.WriteDictionaryStart(); diff --git a/tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs b/tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs new file mode 100644 index 000000000000..9b74e9d69fc9 --- /dev/null +++ b/tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs @@ -0,0 +1,220 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Volumes; +using Files.Platform.Linux.Volumes; +using Files.Platform.Tests.SystemIntegration; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Tmds.DBus.Protocol; + +namespace Files.Platform.Tests.Volumes +{ + /// + /// Unlocking encrypted volumes against on a private bus; no real device or prompt is involved. + /// + [TestClass] + public sealed class UDisks2LuksTests + { + private const string Locked = "/org/freedesktop/UDisks2/block_devices/sde1"; + private const string Cleartext = "/org/freedesktop/UDisks2/block_devices/dm_sde1"; + + private sealed class ScriptedPrompt(params string?[] answers) : IVolumePassphrasePrompt + { + private int next; + + public List Retries { get; } = new(); + + public List Handed { get; } = new(); + + public List Volumes { get; } = new(); + + public Task RequestPassphraseAsync(VolumeInfo volume, bool retry, CancellationToken cancellationToken = default) + { + Retries.Add(retry); + Volumes.Add(volume); + var answer = answers[Math.Min(next++, answers.Length - 1)]; + if (answer is null) + return Task.FromResult(null); + + var bytes = Encoding.UTF8.GetBytes(answer); + Handed.Add(bytes); + return Task.FromResult(bytes); + } + } + + private static async Task<(PrivateBus Bus, FakeUDisks2 Fake)> StartAsync() + { + var bus = PrivateBus.Start(); + var fake = await FakeUDisks2.StartAsync(bus.Address); + fake.AddDrive("Generic_Flash", removable: true); + fake.AddEncrypted("sde1", "Generic_Flash", "correct horse"); + return (bus, fake); + } + + [TestMethod] + public async Task LockedContainerIsListedAsLockedAndReplacedByItsCleartextOnceUnlocked() + { + var (bus, fake) = await StartAsync(); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address)) + { + var locked = (await service.GetVolumesAsync()).Single(); + Assert.AreEqual(Locked, locked.Id); + Assert.IsTrue(locked.IsLocked); + Assert.IsFalse(locked.IsMounted); + Assert.AreEqual("crypto_LUKS", locked.FileSystem); + + fake.AddCleartext("sde1"); + var after = (await service.GetVolumesAsync()).Single(); + Assert.AreEqual(Cleartext, after.Id); + Assert.IsFalse(after.IsLocked); + Assert.AreEqual("SECRET", after.Label); + } + } + + [TestMethod] + public async Task MountUnlocksWithThePassphraseThenMountsTheCleartextDevice() + { + var (bus, fake) = await StartAsync(); + var prompt = new ScriptedPrompt("correct horse"); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, prompt)) + { + var mountPoint = await service.MountAsync(Locked); + + Assert.AreEqual("/run/media/test/dm_sde1", mountPoint); + var calls = fake.CallsSnapshot(); + Assert.AreEqual(2, calls.Count); + Assert.AreEqual((Locked, "org.freedesktop.UDisks2.Encrypted", "Unlock"), (calls[0].Path, calls[0].Interface, calls[0].Member)); + Assert.AreEqual(false, calls[0].Options["auth.no_user_interaction"]); + Assert.AreEqual((Cleartext, "org.freedesktop.UDisks2.Filesystem", "Mount"), (calls[1].Path, calls[1].Interface, calls[1].Member)); + CollectionAssert.AreEqual(new[] { "correct horse" }, fake.ReceivedPassphrases); + Assert.IsFalse(prompt.Retries.Single()); + Assert.AreEqual(Locked, prompt.Volumes.Single().Id); + } + } + + [TestMethod] + public async Task PassphraseBufferIsClearedAfterUse() + { + var (bus, fake) = await StartAsync(); + var prompt = new ScriptedPrompt("correct horse"); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, prompt)) + { + await service.MountAsync(Locked); + Assert.IsTrue(prompt.Handed.Single().All(b => b == 0)); + } + } + + [TestMethod] + public async Task WrongPassphrasePromptsAgainWithRetryAndThenSucceeds() + { + var (bus, fake) = await StartAsync(); + var prompt = new ScriptedPrompt("wrong", "correct horse"); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, prompt)) + { + Assert.AreEqual("/run/media/test/dm_sde1", await service.MountAsync(Locked)); + + CollectionAssert.AreEqual(new[] { false, true }, prompt.Retries); + CollectionAssert.AreEqual(new[] { "wrong", "correct horse" }, fake.ReceivedPassphrases); + Assert.IsTrue(prompt.Handed.All(h => h.All(b => b == 0))); + } + } + + [TestMethod] + public async Task CancellingThePromptThrowsCancellationWithoutTouchingTheDevice() + { + var (bus, fake) = await StartAsync(); + var prompt = new ScriptedPrompt((string?)null); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, prompt)) + { + await Assert.ThrowsExactlyAsync(() => service.MountAsync(Locked)); + Assert.AreEqual(0, fake.CallsSnapshot().Count); + } + } + + [TestMethod] + public async Task CancellingAfterAWrongPassphraseStopsWithoutError() + { + var (bus, fake) = await StartAsync(); + var prompt = new ScriptedPrompt("wrong", null); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, prompt)) + { + await Assert.ThrowsExactlyAsync(() => service.MountAsync(Locked)); + Assert.AreEqual(1, fake.CallsSnapshot().Count); + } + } + + [TestMethod] + public async Task AlreadyUnlockedContainerMountsItsExistingCleartextWithoutPrompting() + { + var (bus, fake) = await StartAsync(); + fake.AddCleartext("sde1"); + var prompt = new ScriptedPrompt("never used"); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, prompt)) + { + Assert.AreEqual("/run/media/test/dm_sde1", await service.MountAsync(Locked)); + + Assert.AreEqual(0, prompt.Retries.Count); + var call = fake.CallsSnapshot().Single(); + Assert.AreEqual((Cleartext, "Mount"), (call.Path, call.Member)); + } + } + + [TestMethod] + public async Task LockedContainerCannotBeMountedWithoutAPrompt() + { + var (bus, fake) = await StartAsync(); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address)) + { + var ex = await Assert.ThrowsExactlyAsync(() => service.MountAsync(Locked)); + Assert.AreEqual(VolumeError.NotSupported, ex.Error); + Assert.AreEqual(0, fake.CallsSnapshot().Count); + } + } + + [TestMethod] + public async Task OtherUnlockFailuresAreReportedNotRetried() + { + var (bus, fake) = await StartAsync(); + fake.Errors["org.freedesktop.UDisks2.Encrypted.Unlock"] = "org.freedesktop.UDisks2.Error.NotAuthorizedCanObtain"; + var prompt = new ScriptedPrompt("correct horse"); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, prompt)) + { + var ex = await Assert.ThrowsExactlyAsync(() => service.MountAsync(Locked)); + Assert.AreEqual(VolumeError.NotAuthorized, ex.Error); + Assert.AreEqual(1, prompt.Retries.Count); + Assert.IsTrue(prompt.Handed.Single().All(b => b == 0)); + } + } + + [TestMethod] + public void TranslateMapsOnlyPassphraseFailuresToIncorrectPassphrase() + { + static VolumeError Map(string name, string message) => UDisks2VolumeService.Translate(new DBusErrorReplyException(name, message)).Error; + + Assert.AreEqual(VolumeError.IncorrectPassphrase, Map("org.freedesktop.UDisks2.Error.Failed", "Error unlocking /dev/sde1: Failed to activate device: Operation not permitted")); + Assert.AreEqual(VolumeError.IncorrectPassphrase, Map("org.freedesktop.UDisks2.Error.Failed", "Invalid passphrase")); + Assert.AreEqual(VolumeError.Failed, Map("org.freedesktop.UDisks2.Error.Failed", "No space left on device")); + Assert.AreEqual(VolumeError.NotAuthorized, Map("org.freedesktop.UDisks2.Error.NotAuthorizedCanObtain", "Not authorized")); + } + + [TestMethod] + public void FindCleartextFallsBackToTheBackingDeviceLinkWhenTheContainerPropertyIsStale() + { + var objects = new UDisks2Objects + { + [Locked] = new() { [UDisks2Parser.BlockInterface] = new(), [UDisks2Parser.EncryptedInterface] = new() { ["CleartextDevice"] = "/" } }, + [Cleartext] = new() { [UDisks2Parser.BlockInterface] = new() { ["CryptoBackingDevice"] = Locked } }, + }; + + Assert.AreEqual(Cleartext, UDisks2Parser.FindCleartext(objects, Locked)); + objects.Remove(Cleartext); + Assert.IsNull(UDisks2Parser.FindCleartext(objects, Locked)); + } + } +} From cfe4e8a36c788e6ebdb8c5ece2061a8811adf386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 2 Nov 2026 16:19:08 +0200 Subject: [PATCH 2/2] Narrow wrong-passphrase detection and cap unlock retries Co-Authored-By: Claude Opus 5.5 --- .../Volumes/UDisks2VolumeService.cs | 11 +++++-- .../Volumes/FakeUDisks2.cs | 5 ++- .../Volumes/UDisks2LuksTests.cs | 31 +++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs b/src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs index 5f350b119055..5606c7c49e9f 100644 --- a/src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs +++ b/src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs @@ -30,6 +30,8 @@ public sealed class UDisks2VolumeService : IVolumeService private static readonly TimeSpan RefreshDelay = TimeSpan.FromMilliseconds(150); private readonly string? busAddress; + private const int MaxPassphraseAttempts = 5; + private readonly IVolumePassphrasePrompt? passphrasePrompt; private readonly SemaphoreSlim connectGate = new(1, 1); private readonly object watchGate = new(); @@ -235,9 +237,11 @@ private async Task ResolveCleartextAsync(string volumeId, CancellationTo var prompt = passphrasePrompt ?? throw new VolumeOperationException(VolumeError.NotSupported, "Unlocking encrypted volumes is not available."); + var attempts = 0; var retry = false; while (true) { + attempts++; var passphrase = await prompt.RequestPassphraseAsync(volume, retry, cancellationToken).ConfigureAwait(false); if (passphrase is null) throw new OperationCanceledException("The passphrase prompt was cancelled."); @@ -246,7 +250,7 @@ private async Task ResolveCleartextAsync(string volumeId, CancellationTo { return await UnlockAsync(volumeId, passphrase, cancellationToken).ConfigureAwait(false); } - catch (VolumeOperationException ex) when (ex.Error == VolumeError.IncorrectPassphrase) + catch (VolumeOperationException ex) when (ex.Error == VolumeError.IncorrectPassphrase && attempts < MaxPassphraseAttempts) { retry = true; } @@ -340,7 +344,7 @@ public static VolumeOperationException Translate(DBusErrorReplyException ex) var message = ex.ErrorMessage ?? ""; var error = name switch { - // UDisks2 reports a wrong passphrase as a generic Failed error; libblockdev/cryptsetup words it as EPERM or "passphrase" + // UDisks2 reports a wrong passphrase as a generic Failed error; cryptsetup words a bad key as EPERM from "Failed to activate device", or "passphrase" _ when name.EndsWith("Error.Failed", StringComparison.Ordinal) && IsPassphraseFailure(message) => VolumeError.IncorrectPassphrase, _ when name.Contains("NotAuthorized", StringComparison.Ordinal) => VolumeError.NotAuthorized, _ when name.EndsWith("DeviceBusy", StringComparison.Ordinal) => VolumeError.Busy, @@ -353,7 +357,8 @@ _ when name.EndsWith("UnknownObject", StringComparison.Ordinal) || name.EndsWith } private static bool IsPassphraseFailure(string message) - => message.Contains("Operation not permitted", StringComparison.OrdinalIgnoreCase) + => (message.Contains("Failed to activate device", StringComparison.OrdinalIgnoreCase) + && message.Contains("Operation not permitted", StringComparison.OrdinalIgnoreCase)) || message.Contains("passphrase", StringComparison.OrdinalIgnoreCase) || message.Contains("No key available", StringComparison.OrdinalIgnoreCase); diff --git a/tests/Files.Platform.Tests/Volumes/FakeUDisks2.cs b/tests/Files.Platform.Tests/Volumes/FakeUDisks2.cs index ab29a7d74a33..251408626015 100644 --- a/tests/Files.Platform.Tests/Volumes/FakeUDisks2.cs +++ b/tests/Files.Platform.Tests/Volumes/FakeUDisks2.cs @@ -36,6 +36,9 @@ internal sealed class FakeUDisks2 : IPathMethodHandler, IDisposable /// Errors to answer with, keyed by Interface.Member. public Dictionary Errors { get; } = new(); + /// Messages for , keyed the same way (default "fake failure"). + public Dictionary ErrorMessages { get; } = new(); + /// Block device name to the passphrase that unlocks it (). public Dictionary Passphrases { get; } = new(); @@ -294,7 +297,7 @@ private ValueTask HandleUnlock(MethodContext context, ref Reader reader) if (Errors.TryGetValue(UDisks2Parser.EncryptedInterface + ".Unlock", out var error)) { - context.ReplyError(error, "fake failure"); + context.ReplyError(error, ErrorMessages.GetValueOrDefault(UDisks2Parser.EncryptedInterface + ".Unlock", "fake failure")); return ValueTask.CompletedTask; } diff --git a/tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs b/tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs index 9b74e9d69fc9..10c8510cf2be 100644 --- a/tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs +++ b/tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs @@ -188,10 +188,40 @@ public async Task OtherUnlockFailuresAreReportedNotRetried() var ex = await Assert.ThrowsExactlyAsync(() => service.MountAsync(Locked)); Assert.AreEqual(VolumeError.NotAuthorized, ex.Error); Assert.AreEqual(1, prompt.Retries.Count); + Assert.IsFalse(prompt.Retries[0]); Assert.IsTrue(prompt.Handed.Single().All(b => b == 0)); } } + [TestMethod] + public async Task PermissionErrorWithoutActivationWordingIsNotRetried() + { + var (bus, fake) = await StartAsync(); + fake.Errors["org.freedesktop.UDisks2.Encrypted.Unlock"] = "org.freedesktop.UDisks2.Error.Failed"; + fake.ErrorMessages["org.freedesktop.UDisks2.Encrypted.Unlock"] = "Error unlocking /dev/sde1: Failed to load dm-crypt: Operation not permitted"; + var prompt = new ScriptedPrompt("correct horse"); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, prompt)) + { + var ex = await Assert.ThrowsExactlyAsync(() => service.MountAsync(Locked)); + Assert.AreEqual(VolumeError.Failed, ex.Error); + Assert.AreEqual(1, prompt.Retries.Count); + } + } + + [TestMethod] + public async Task WrongPassphraseStopsPromptingAfterTheRetryLimit() + { + var (bus, fake) = await StartAsync(); + var prompt = new ScriptedPrompt("wrong"); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, prompt)) + { + var ex = await Assert.ThrowsExactlyAsync(() => service.MountAsync(Locked)); + Assert.AreEqual(VolumeError.IncorrectPassphrase, ex.Error); + CollectionAssert.AreEqual(new[] { false, true, true, true, true }, prompt.Retries); + Assert.AreEqual(5, fake.ReceivedPassphrases.Count); + } + } + [TestMethod] public void TranslateMapsOnlyPassphraseFailuresToIncorrectPassphrase() { @@ -199,6 +229,7 @@ public void TranslateMapsOnlyPassphraseFailuresToIncorrectPassphrase() Assert.AreEqual(VolumeError.IncorrectPassphrase, Map("org.freedesktop.UDisks2.Error.Failed", "Error unlocking /dev/sde1: Failed to activate device: Operation not permitted")); Assert.AreEqual(VolumeError.IncorrectPassphrase, Map("org.freedesktop.UDisks2.Error.Failed", "Invalid passphrase")); + Assert.AreEqual(VolumeError.Failed, Map("org.freedesktop.UDisks2.Error.Failed", "Failed to load dm-crypt: Operation not permitted")); Assert.AreEqual(VolumeError.Failed, Map("org.freedesktop.UDisks2.Error.Failed", "No space left on device")); Assert.AreEqual(VolumeError.NotAuthorized, Map("org.freedesktop.UDisks2.Error.NotAuthorizedCanObtain", "Not authorized")); }