using System.Runtime.Versioning; using System.Security.Cryptography; using DodoSSH.Client.Session; namespace DodoSSH.Client.App.Platform; /// /// Picks the device key store this desktop machine can actually offer. /// /// /// /// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware /// gets and therefore keeps asking for the passphrase — which is /// the honest answer rather than a degraded one. /// /// /// Every real store is asked whether it works rather than told that it does. Each /// IsSupported 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. /// /// /// The three are not equally strong, and the ordering here does not imply they are. 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 checking is the TPM's. That is a real difference, it is /// recorded in ADR 0007 rather than smoothed over, and it is why is /// tried last rather than first among platforms that could in principle overlap. /// /// /// "Desktop", because the choice belongs to a head rather than to the session layer. This file used /// to live in DodoSSH.Client.Session, 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 , which was already there — the session takes a /// store and has never known which one. See docs/android-port.md. /// /// public static class DesktopDeviceKeyStores { /// The best store this machine supports, chosen on the thread pool. /// /// Returns at once: the probing that decides between them is slow — a TPM key created and deleted on /// Windows — and runs behind rather than here, which is on the UI /// thread at startup. /// /// Where a stored key lives. /// /// 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 App.axaml.cs. /// public static IDeviceKeyStore ForThisMachine( ClientPaths paths, Func> askForPin) { ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(askForPin); return new ProbedDeviceKeyStore(() => Choose(paths, askForPin)); } private static IDeviceKeyStore Choose( ClientPaths paths, Func> 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(); } } /// /// Keeps the device key encrypted to a TPM-resident key whose use requires the user's consent. /// /// /// /// The consent is enforced by CNG, not by this class, 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 /// CryptUnprotectData itself. Here the unwrapping key lives in the TPM under /// , so the Windows consent dialog is a condition of /// using the key. Malware can ask; it cannot answer, and the attempt is visible. /// /// /// 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. /// /// /// 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. /// /// /// Both ends prompt, and that was measured rather than assumed. Encrypting uses only the public /// half, so it was reasonable to expect sealing to be silent — it is not. CngKey.Create with this /// UI policy blocks on a dialog at creation, because ProtectKey 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. /// /// /// That is the right shape for an opt-in feature, but it has two consequences worth knowing before /// touching this file. Every method except and the empty case of /// needs an interactive desktop, so none of them can be exercised by an /// automated test — see WindowsDeviceKeyStoreTests for where that line falls. And a caller must /// treat as user-facing: it belongs on a UI thread, behind a button somebody /// pressed, never on a background pass. /// /// [SupportedOSPlatform("windows")] public sealed class WindowsDeviceKeyStore : IDeviceKeyStore { /// /// 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. /// /// This is the default profile's key, and the name every profile shared before keys were per profile; /// see and . /// /// private const string KeyName = "DodoSSH.DeviceKey.v1"; private const string PlatformProvider = "Microsoft Platform Crypto Provider"; /// Shown in the Windows consent dialog, so it has to read as a sentence to a person. private const string ConsentPrompt = "Unlock your DodoSSH vault"; private readonly ClientPaths paths; /// This profile's own key. /// /// 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 /// 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. /// private readonly string keyName; /// Creates the store. public WindowsDeviceKeyStore(ClientPaths paths) { ArgumentNullException.ThrowIfNull(paths); this.paths = paths; keyName = DeviceKeyNames.For(paths, KeyName); } /// /// Whether this machine has a TPM the platform provider will hold a key in. /// /// /// 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. /// 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; } } /// public ValueTask IsAvailableAsync(CancellationToken cancellationToken) => ValueTask.FromResult(IsSupported()); /// public async ValueTask SaveAsync( ReadOnlyMemory 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); } /// public async ValueTask 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); } /// 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; } /// /// 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 /// UnlockStatus.DeviceKeyUnavailable. /// 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; } } /// /// 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. /// 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, }); } }