Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/Files.App/Dialogs/CredentialDialog.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/Files.App/Helpers/Application/AppLifecycleHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ public static IServiceProvider ConfigureHost(AppModel appModel)
services.AddFtpStorables();
Files.App.Storage.Archives.ArchiveServiceCollectionExtensions.AddArchiveStorables(services);
services.AddSingleton<Files.Platform.Abstractions.Archives.IArchivePasswordPrompt, Files.App.Actions.ArchivePasswordPrompt>();
services.AddSingleton<Files.Platform.Abstractions.Volumes.IVolumePassphrasePrompt, Files.App.Utils.VolumePassphrasePrompt>();

// Conditional DI
services.AddSingleton<IUpdateService, DummyUpdateService>();
Expand Down
6 changes: 6 additions & 0 deletions src/Files.App/Strings/en-US/Resources.resw
Original file line number Diff line number Diff line change
Expand Up @@ -4907,6 +4907,12 @@
<data name="LinuxVolumeNotAuthorized" xml:space="preserve">
<value>You are not authorized to do this.</value>
</data>
<data name="LinuxVolumeUnlockTitle" xml:space="preserve">
<value>Unlock encrypted volume</value>
</data>
<data name="LinuxVolumeUnlockDescription" xml:space="preserve">
<value>Enter the passphrase to unlock {0}:</value>
</data>
<data name="LinuxVolumeNotMounted" xml:space="preserve">
<value>This drive is not mounted.</value>
</data>
Expand Down
39 changes: 39 additions & 0 deletions src/Files.App/Utils/Global/VolumePassphrasePrompt.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Asks for the passphrase of a locked encrypted volume with the credential dialog.
/// </summary>
internal sealed class VolumePassphrasePrompt : IVolumePassphrasePrompt
{
public async Task<byte[]?> 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<IDialogService>();
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();
}
}
}
5 changes: 5 additions & 0 deletions src/Files.App/Utils/Storage/Helpers/DriveHelpers.Linux.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions src/Files.App/ViewModels/Dialogs/CredentialDialogViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ public string? UserName
set => SetProperty(ref _UserName, value);
}

/// <summary>
/// Overrides the default dialog title when set.
/// </summary>
public string? Title { get; set; }

/// <summary>
/// Overrides the default description when set.
/// </summary>
public string? Description { get; set; }

private bool _IsAnonymous;
public bool IsAnonymous
{
Expand Down
28 changes: 26 additions & 2 deletions src/Files.Platform.Abstractions/Volumes/IVolumeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ namespace Files.Platform.Abstractions.Volumes
/// <param name="DriveId">The identifier of the drive the volume lives on, if known.</param>
/// <param name="CanEject">The drive can be ejected.</param>
/// <param name="CanPowerOff">The drive can be powered off.</param>
/// <param name="IsLocked">The volume is an encrypted (LUKS) container that has to be unlocked with a passphrase before it can be mounted.</param>
public sealed record VolumeInfo(
string Id,
string Device,
Expand All @@ -37,7 +38,8 @@ public sealed record VolumeInfo(
IReadOnlyList<string> MountPoints,
string? DriveId = null,
bool CanEject = false,
bool CanPowerOff = false)
bool CanPowerOff = false,
bool IsLocked = false)
{
/// <summary>Gets whether the volume is mounted.</summary>
public bool IsMounted => MountPoints.Count > 0;
Expand Down Expand Up @@ -99,6 +101,9 @@ public enum VolumeError

/// <summary>The volume does not exist (any more).</summary>
NotFound,

/// <summary>The passphrase did not unlock the encrypted volume.</summary>
IncorrectPassphrase,
}

/// <summary>
Expand All @@ -116,6 +121,21 @@ public VolumeOperationException(VolumeError error, string message, Exception? in
public VolumeError Error { get; }
}

/// <summary>
/// Asks the user for the passphrase of an encrypted volume. Implemented by the UI layer.
/// </summary>
public interface IVolumePassphrasePrompt
{
/// <summary>
/// Requests the passphrase of <paramref name="volume"/> 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.
/// </summary>
/// <param name="volume">The locked volume.</param>
/// <param name="retry">The previous passphrase was wrong.</param>
/// <param name="cancellationToken">Cancels the prompt.</param>
Task<byte[]?> RequestPassphraseAsync(VolumeInfo volume, bool retry, CancellationToken cancellationToken = default);
}

/// <summary>
/// Lists, mounts and unmounts volumes and reports when they come and go.
/// </summary>
Expand All @@ -136,8 +156,12 @@ public interface IVolumeService : IDisposable
/// <summary>Lists the volumes (mounted or not), excluding ones the backend hides.</summary>
Task<IReadOnlyList<VolumeInfo>> GetVolumesAsync(CancellationToken cancellationToken = default);

/// <summary>Mounts the volume (polkit may prompt) and returns the mount point.</summary>
/// <summary>
/// Mounts the volume (polkit may prompt) and returns the mount point.
/// A locked encrypted volume is unlocked first, asking for the passphrase through <see cref="IVolumePassphrasePrompt"/>.
/// </summary>
/// <exception cref="VolumeOperationException">The operation failed.</exception>
/// <exception cref="OperationCanceledException">The user cancelled the passphrase prompt.</exception>
Task<string> MountAsync(string volumeId, CancellationToken cancellationToken = default);

/// <summary>Unmounts the volume.</summary>
Expand Down
38 changes: 35 additions & 3 deletions src/Files.Platform.Linux/Volumes/UDisks2Model.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ public static class UDisks2Parser
/// <summary>Interface names.</summary>
public const string LoopInterface = "org.freedesktop.UDisks2.Loop";

/// <summary>Interface names.</summary>
public const string EncryptedInterface = "org.freedesktop.UDisks2.Encrypted";

/// <summary>Whether <paramref name="id"/> looks like a UDisks2 block device object path (no further interpretation of the string is done by callers).</summary>
public static bool IsBlockPath(string? id)
{
Expand All @@ -156,8 +159,15 @@ public static IReadOnlyList<VolumeInfo> 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<string, object?>? fs;
if (locked)
fs = [];
else if (!interfaces.TryGetValue(FilesystemInterface, out fs))
continue;

if (Bool(block, "HintIgnore"))
Expand Down Expand Up @@ -207,13 +217,35 @@ public static IReadOnlyList<VolumeInfo> BuildVolumes(UDisks2Objects objects)
mountPoints,
driveId,
canEject,
canPowerOff));
canPowerOff,
locked));
}

result.Sort(static (a, b) => string.CompareOrdinal(a.Device, b.Device));
return result;
}

/// <summary>
/// Finds the unlocked cleartext device of the encrypted block device <paramref name="path"/>, or null if it is locked (or not encrypted).
/// </summary>
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;
}

/// <summary>Whether <paramref name="mountPoint"/> belongs to the system rather than being a user-visible drive.</summary>
public static bool IsPlumbingMountPoint(string mountPoint)
{
Expand Down
106 changes: 105 additions & 1 deletion src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -40,10 +44,12 @@ public sealed class UDisks2VolumeService : IVolumeService

/// <summary>
/// Creates the service. <paramref name="busAddress"/> null means the system bus; tests pass a private bus.
/// <paramref name="passphrasePrompt"/> is asked for the passphrase of locked encrypted volumes; without it they cannot be mounted.
/// </summary>
public UDisks2VolumeService(string? busAddress = null)
public UDisks2VolumeService(string? busAddress = null, IVolumePassphrasePrompt? passphrasePrompt = null)
{
this.busAddress = busAddress;
this.passphrasePrompt = passphrasePrompt;
}

/// <inheritdoc/>
Expand Down Expand Up @@ -135,6 +141,7 @@ public void StopWatching()
public async Task<string> 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
Expand Down Expand Up @@ -205,6 +212,94 @@ public void Dispose()
connectGate.Dispose();
}

/// <summary>Returns the volume to mount: the volume itself, or for an encrypted container its cleartext device (unlocking it first if needed).</summary>
private async Task<string> 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<string> 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<string, VariantValue> { ["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);
Expand Down Expand Up @@ -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,
Expand All @@ -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<T> CallAsync<T>(string path, string iface, string member, MessageValueReader<T> reader, CancellationToken cancellationToken)
{
var bus = await GetConnectionAsync().ConfigureAwait(false)
Expand Down
Loading
Loading