Public Access
317 lines
14 KiB
C#
317 lines
14 KiB
C#
using System.Runtime.Versioning;
|
|
using System.Security.Cryptography;
|
|
using DodoSSH.Client.Session;
|
|
|
|
namespace DodoSSH.Client.App.Platform;
|
|
|
|
/// <summary>
|
|
/// Picks the device key store this desktop machine can actually offer.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware
|
|
/// gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the passphrase — which is
|
|
/// the honest answer rather than a degraded one.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Every real store is asked whether it works rather than told that it does.</b> Each
|
|
/// <c>IsSupported</c> probes by doing the thing — creating a throwaway key and discarding it — because on
|
|
/// all three platforms the provider is present and reports itself present on machines where creating a key
|
|
/// fails: a Windows box with no usable TPM, a Mac with no Secure Enclave, on macOS also every unsigned
|
|
/// development build since enclave keys need a signing identity, and on Linux the ordinary case of a
|
|
/// working TPM whose device node this user may not open. Inferring from the OS would mean each of those
|
|
/// discovering the truth at the moment somebody tried to unlock.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The three are not equally strong, and the ordering here does not imply they are.</b> Windows and
|
|
/// macOS put the consent gesture inside the platform — a secure desktop and a system Touch ID sheet — so
|
|
/// the process asking never sees the secret. The Linux store's PIN is typed into a window this
|
|
/// application draws, and only the <em>checking</em> is the TPM's. That is a real difference, it is
|
|
/// recorded in ADR 0007 rather than smoothed over, and it is why <see cref="LinuxDeviceKeyStore"/> is
|
|
/// tried last rather than first among platforms that could in principle overlap.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used
|
|
/// to live in <c>DodoSSH.Client.Session</c>, which was the one thing keeping that project from being
|
|
/// portable: everything else in it is platform-neutral, and a Windows CNG dependency in the middle of the
|
|
/// vault code meant a second head could not reference it without dragging Windows along. The seam that
|
|
/// makes the move free is <see cref="IDeviceKeyStore"/>, which was already there — the session takes a
|
|
/// store and has never known which one. See <c>docs/android-port.md</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class DesktopDeviceKeyStores
|
|
{
|
|
/// <summary>The best store this machine supports, chosen on the thread pool.</summary>
|
|
/// <remarks>
|
|
/// Returns at once: the probing that decides between them is slow — a TPM key created and deleted on
|
|
/// Windows — and runs behind <see cref="ProbedDeviceKeyStore"/> rather than here, which is on the UI
|
|
/// thread at startup.
|
|
/// </remarks>
|
|
/// <param name="paths">Where a stored key lives.</param>
|
|
/// <param name="askForPin">
|
|
/// Shows the Linux PIN prompt, and is never called on the other two platforms — there the platform
|
|
/// raises its own dialog and this process is not in the conversation. A caller with no way to draw one
|
|
/// may pass a delegate answering null, which makes the Linux store decline every unlock; that is the
|
|
/// right shape for a headless host and the wrong one for the application, which supplies a real dialog
|
|
/// in <c>App.axaml.cs</c>.
|
|
/// </param>
|
|
public static IDeviceKeyStore ForThisMachine(
|
|
ClientPaths paths,
|
|
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(paths);
|
|
ArgumentNullException.ThrowIfNull(askForPin);
|
|
|
|
return new ProbedDeviceKeyStore(() => Choose(paths, askForPin));
|
|
}
|
|
|
|
private static IDeviceKeyStore Choose(
|
|
ClientPaths paths,
|
|
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
|
|
{
|
|
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
|
|
{
|
|
return new WindowsDeviceKeyStore(paths);
|
|
}
|
|
|
|
if (OperatingSystem.IsMacOS() && MacDeviceKeyStore.IsSupported())
|
|
{
|
|
return new MacDeviceKeyStore(paths);
|
|
}
|
|
|
|
if (OperatingSystem.IsLinux() && LinuxDeviceKeyStore.IsSupported())
|
|
{
|
|
return new LinuxDeviceKeyStore(paths, askForPin);
|
|
}
|
|
|
|
return new UnavailableDeviceKeyStore();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Keeps the device key encrypted to a TPM-resident key whose use requires the user's consent.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>The consent is enforced by CNG, not by this class</b>, and that distinction is the entire security
|
|
/// value. A store that read a DPAPI blob after showing its own prompt would be trivially bypassed:
|
|
/// malware running as the user would skip the prompt, read the file and call
|
|
/// <c>CryptUnprotectData</c> itself. Here the unwrapping key lives in the TPM under
|
|
/// <see cref="CngUIProtectionLevels.ProtectKey"/>, so the Windows consent dialog is a condition of
|
|
/// <em>using</em> the key. Malware can ask; it cannot answer, and the attempt is visible.
|
|
/// </para>
|
|
/// <para>
|
|
/// This is a refinement of what ADR 0007 describes, and stronger than it: the ADR reasoned about a
|
|
/// gesture gating a protected blob and did not notice that a gate inside the process is not a gate. The
|
|
/// mechanism recorded there has been corrected to match this.
|
|
/// </para>
|
|
/// <para>
|
|
/// The TPM key is RSA rather than the ECDH one might expect, because it is used to encrypt 32 bytes and
|
|
/// nothing else. OAEP over a 2048-bit key carries 190, so there is no need for an agreement step, and no
|
|
/// need for the device key itself to be an algorithm the TPM understands — which is what keeps the DSH1
|
|
/// device wrap unchanged at X25519. See ADR 0007 for why changing that is a separate decision.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Both ends prompt, and that was measured rather than assumed.</b> Encrypting uses only the public
|
|
/// half, so it was reasonable to expect sealing to be silent — it is not. <c>CngKey.Create</c> with this
|
|
/// UI policy blocks on a dialog at <em>creation</em>, because <c>ProtectKey</c> means "protect this key
|
|
/// with a PIN", and Windows asks the user to set that up there and then. So registering a device shows one
|
|
/// setup dialog and every unlock shows a consent dialog.
|
|
/// </para>
|
|
/// <para>
|
|
/// That is the right shape for an opt-in feature, but it has two consequences worth knowing before
|
|
/// touching this file. Every method except <see cref="IsSupported"/> and the empty case of
|
|
/// <see cref="TryLoadAsync"/> needs an interactive desktop, so <b>none of them can be exercised by an
|
|
/// automated test</b> — see <c>WindowsDeviceKeyStoreTests</c> for where that line falls. And a caller must
|
|
/// treat <see cref="SaveAsync"/> as user-facing: it belongs on a UI thread, behind a button somebody
|
|
/// pressed, never on a background pass.
|
|
/// </para>
|
|
/// </remarks>
|
|
[SupportedOSPlatform("windows")]
|
|
public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
|
{
|
|
/// <remarks>
|
|
/// Versioned, so a future change of algorithm or padding can create a new key beside the old one
|
|
/// rather than failing to open blobs written by a previous build. A device that cannot be opened
|
|
/// falls back to the passphrase, which is survivable — but silently, and a user would only notice
|
|
/// their gesture had stopped working.
|
|
/// <para>
|
|
/// This is the default profile's key, and the name every profile shared before keys were per profile;
|
|
/// see <see cref="DeviceKeyNames"/> and <see cref="keyName"/>.
|
|
/// </para>
|
|
/// </remarks>
|
|
private const string KeyName = "DodoSSH.DeviceKey.v1";
|
|
|
|
private const string PlatformProvider = "Microsoft Platform Crypto Provider";
|
|
|
|
/// <remarks>Shown in the Windows consent dialog, so it has to read as a sentence to a person.</remarks>
|
|
private const string ConsentPrompt = "Unlock your DodoSSH vault";
|
|
|
|
private readonly ClientPaths paths;
|
|
|
|
/// <summary>This profile's own key.</summary>
|
|
/// <remarks>
|
|
/// <see cref="KeyName"/> for the default profile and a name of its own for every other — so signing out
|
|
/// of the nightly no longer deletes the key the release build's device unlock is sealed to. A profile
|
|
/// registered before this still has its blob sealed to the shared key, and <see cref="TryLoadAsync"/>
|
|
/// keeps opening it there until the next registration re-seals it here. It is never deleted from a
|
|
/// profile that does not own it, which is the half of this that matters.
|
|
/// </remarks>
|
|
private readonly string keyName;
|
|
|
|
/// <summary>Creates the store.</summary>
|
|
public WindowsDeviceKeyStore(ClientPaths paths)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(paths);
|
|
this.paths = paths;
|
|
keyName = DeviceKeyNames.For(paths, KeyName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether this machine has a TPM the platform provider will hold a key in.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Probed by creating a throwaway key and deleting it, rather than by asking the provider whether it
|
|
/// exists. The provider is registered on machines with no usable TPM as well, and reports itself
|
|
/// present right up to the point where creating a key fails — so the only honest test is the one that
|
|
/// does the thing. No UI policy on the probe, so nothing prompts.
|
|
/// </remarks>
|
|
internal static bool IsSupported()
|
|
{
|
|
var probe = $"DodoSSH.Probe.{Guid.CreateVersion7():N}";
|
|
|
|
try
|
|
{
|
|
using var key = CngKey.Create(
|
|
CngAlgorithm.Rsa,
|
|
probe,
|
|
new CngKeyCreationParameters { Provider = new CngProvider(PlatformProvider) });
|
|
|
|
key.Delete();
|
|
return true;
|
|
}
|
|
catch (CryptographicException)
|
|
{
|
|
return false;
|
|
}
|
|
catch (PlatformNotSupportedException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
|
|
ValueTask.FromResult(IsSupported());
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask SaveAsync(
|
|
ReadOnlyMemory<byte> devicePrivateKey,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var key = OpenOrCreate(keyName);
|
|
using var rsa = new RSACng(key);
|
|
|
|
var sealedKey = rsa.Encrypt(devicePrivateKey.Span, RSAEncryptionPadding.OaepSHA256);
|
|
|
|
paths.EnsureCreated();
|
|
|
|
await File.WriteAllBytesAsync(paths.DeviceKeyFile, sealedKey, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (!File.Exists(paths.DeviceKeyFile))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
// The shared key only while this profile has none of its own: a profile registered before keys were
|
|
// per profile is still sealed to it. Asking both would show two consent dialogs for one unlock.
|
|
var sealedTo = !string.Equals(keyName, KeyName, StringComparison.Ordinal) && !CngKey.Exists(keyName, new CngProvider(PlatformProvider))
|
|
? KeyName
|
|
: keyName;
|
|
|
|
return Unseal(sealedKey, sealedTo);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask ForgetAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (File.Exists(paths.DeviceKeyFile))
|
|
{
|
|
File.Delete(paths.DeviceKeyFile);
|
|
}
|
|
|
|
// This profile's key and never the shared one, unless this is the profile that owns it. Another
|
|
// profile's device unlock may still be sealed to it.
|
|
if (CngKey.Exists(keyName, new CngProvider(PlatformProvider)))
|
|
{
|
|
using var key = CngKey.Open(keyName, new CngProvider(PlatformProvider));
|
|
key.Delete();
|
|
}
|
|
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// This is the call that prompts. Every failure becomes null, and the set is wider than it looks: the
|
|
/// key may be gone, the user may have cancelled, the TPM may be locked out after too many wrong PINs,
|
|
/// or the blob may predate a key that has since been replaced. None of them are distinguishable to a
|
|
/// user and all have the same remedy, so none of them are worth telling apart here — see
|
|
/// <c>UnlockStatus.DeviceKeyUnavailable</c>.
|
|
/// </remarks>
|
|
private static byte[]? Unseal(byte[] sealedKey, string name)
|
|
{
|
|
try
|
|
{
|
|
if (!CngKey.Exists(name, new CngProvider(PlatformProvider)))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
using var key = CngKey.Open(name, new CngProvider(PlatformProvider));
|
|
using var rsa = new RSACng(key);
|
|
|
|
return rsa.Decrypt(sealedKey, RSAEncryptionPadding.OaepSHA256);
|
|
}
|
|
catch (CryptographicException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The UI policy is set at creation and cannot be added afterwards, which is why this opens an existing
|
|
/// key rather than ever reconfiguring one: a key created without the policy would decrypt silently, and
|
|
/// silently is the one behaviour this whole file exists to prevent.
|
|
/// </remarks>
|
|
private static CngKey OpenOrCreate(string name)
|
|
{
|
|
var provider = new CngProvider(PlatformProvider);
|
|
|
|
if (CngKey.Exists(name, provider))
|
|
{
|
|
return CngKey.Open(name, provider);
|
|
}
|
|
|
|
return CngKey.Create(
|
|
CngAlgorithm.Rsa,
|
|
name,
|
|
new CngKeyCreationParameters
|
|
{
|
|
Provider = provider,
|
|
UIPolicy = new CngUIPolicy(CngUIProtectionLevels.ProtectKey, ConsentPrompt),
|
|
|
|
// Machine-wide would put one key behind every account on the computer. This key stands for
|
|
// "this user, on this machine", which is what a device wrap means.
|
|
KeyCreationOptions = CngKeyCreationOptions.None,
|
|
});
|
|
}
|
|
}
|