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..5606c7c49e9f 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,9 @@ 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(); private DBusConnection? connection; @@ -40,10 +44,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 +141,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 +212,94 @@ 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 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."); + + try + { + return await UnlockAsync(volumeId, passphrase, cancellationToken).ConfigureAwait(false); + } + catch (VolumeOperationException ex) when (ex.Error == VolumeError.IncorrectPassphrase && attempts < MaxPassphraseAttempts) + { + 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 +341,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; 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, _ when name.EndsWith("NotSupported", StringComparison.Ordinal) => VolumeError.NotSupported, @@ -258,6 +356,12 @@ _ when name.EndsWith("UnknownObject", StringComparison.Ordinal) || name.EndsWith return new VolumeOperationException(error, ex.ErrorMessage ?? name, ex); } + private static bool IsPassphraseFailure(string message) + => (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); + 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..251408626015 100644 --- a/tests/Files.Platform.Tests/Volumes/FakeUDisks2.cs +++ b/tests/Files.Platform.Tests/Volumes/FakeUDisks2.cs @@ -36,6 +36,15 @@ 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(); + + /// The passphrases received by Unlock, in order. + public List ReceivedPassphrases { get; } = new(); + public string Path => Root; public bool HandlesChildPaths => true; @@ -106,6 +115,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 +246,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 +280,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, ErrorMessages.GetValueOrDefault(UDisks2Parser.EncryptedInterface + ".Unlock", "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..10c8510cf2be --- /dev/null +++ b/tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs @@ -0,0 +1,251 @@ +// 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.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() + { + 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", "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")); + } + + [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)); + } + } +}