Files
DodoSSH/src/DodoSSH.Client.App/Platform/WindowsDeviceKeyStore.cs
T
jaap-jan 890a5f2246
ci / android head (pull_request) Canceled after 0s
ci / desktop nightly (pull_request) Canceled after 0s
ci / api image (pull_request) Canceled after 0s
ci / build and test (pull_request) Canceled after 1m21s
Give the desktop a macOS head, signed from the first release
The same application, the same Velopack and the same two-phase person-run
release as Windows, with four things forced to differ. Signing is a
precondition rather than an improvement: Gatekeeper refuses an
un-notarized download outright instead of warning about it, so there was
never the "unsigned for now" that ADR 0013 decision 8 argues for on
Windows, and release-macos.sh refuses to start without the identities.

The packaging split is narrower than it first looked, and the old claim
at the foot of ci.yml is why it was worth checking rather than assuming.
vpk cross-compiles when told to: 'vpk [osx] bundle' builds a real .app on
any platform, and CI now publishes osx-arm64 and bundles it on every main
and tag build, which is what catches a restore graph with no macOS native
asset. There is no '[osx] pack' off a Mac, and that part is correct — pack
drives codesign, notarytool and stapler, which exist nowhere else.

The dylib signing loop in the script looks redundant beside vpk's own
pass and is not. vpk signs with 'codesign --deep', which is the shape
Apple documents as wrong for nested code, and platform-flags has recorded
a notarization rejection that names no file since before any of this
existed. Signing each native binary inside-out first leaves that pass
nothing to get wrong.

MacDeviceKeyStore reaches ADR 0007's conclusion through different
hardware: a P-256 key in the Secure Enclave under an access control
requiring user presence, so the platform enforces the gate rather than
this process — which is the whole point of that ADR's amendment. The
enclave holds no other kind of key, hence ECIES where Windows uses
RSA-OAEP, and the shape that falls out is better than the Windows one:
sealing needs only the public half and is silent, so only unlock prompts.
IsSupported probes rather than infers, because three ordinary Macs answer
no — an Intel machine without a T2, one with no login password, and every
unsigned development build, since enclave keys need a signing identity.

Two decisions worth stating because they are reversible. arm64 only: a
second channel is small work and nobody here has an Intel Mac to walk
Phase 18 on, and an x64 package would be the only artefact in this
repository reaching users unverified. And the pack id stays
DodoSSH.Desktop even though vpk names the bundle after it, so
/Applications holds DodoSSH.Desktop.app: decision 2's reasoning binds
harder here, because a pack id of DodoSSH would put Velopack's install
root on top of ClientPaths.DataDirectory and let an uninstall take the
user's un-synced outbox with it. CFBundleDisplayName puts the product
name back in front of a person.

Measured rather than assumed, since none of it is obvious: the publish
and the bundle were both run, LSMinimumSystemVersion is 12.0 because that
is the minos in the apphost's own LC_BUILD_VERSION, and vpk copies a
custom Info.plist verbatim with no substitution at all — which is why the
plist is a template the script renders and not a committed file.

What is not done is the half that needs the hardware. There is no macOS
runner, so nothing past "it bundles" has ever run. Phase 18 is the whole
of the verification, and the two checks most likely to fail are the
terminal against WKWebView and the enclave interop, neither of which has
executed once.
2026-08-10 10:43:28 +02:00

257 lines
10 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,
/// or one that is neither Windows nor macOS, gets <see cref="UnavailableDeviceKeyStore"/> and therefore
/// keeps asking for the passphrase — which is the honest answer rather than a degraded one.
/// </para>
/// <para>
/// <b>Both real stores are asked whether they work rather than told that they do.</b> Each
/// <c>IsSupported</c> probes by doing the thing — creating a throwaway key and deleting it — because on
/// both 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, and on macOS also every
/// unsigned development build, since enclave keys need a signing identity. Inferring from the OS would
/// mean each of those discovering the truth at the moment somebody tried to unlock.
/// </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.</summary>
public static IDeviceKeyStore ForThisMachine(ClientPaths paths)
{
ArgumentNullException.ThrowIfNull(paths);
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
{
return new WindowsDeviceKeyStore(paths);
}
if (OperatingSystem.IsMacOS() && MacDeviceKeyStore.IsSupported())
{
return new MacDeviceKeyStore(paths);
}
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.
/// </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>Creates the store.</summary>
public WindowsDeviceKeyStore(ClientPaths paths)
{
ArgumentNullException.ThrowIfNull(paths);
this.paths = paths;
}
/// <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();
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);
return Unseal(sealedKey);
}
/// <inheritdoc />
public ValueTask ForgetAsync(CancellationToken cancellationToken)
{
if (File.Exists(paths.DeviceKeyFile))
{
File.Delete(paths.DeviceKeyFile);
}
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)
{
try
{
if (!CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
{
return null;
}
using var key = CngKey.Open(KeyName, 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()
{
var provider = new CngProvider(PlatformProvider);
if (CngKey.Exists(KeyName, provider))
{
return CngKey.Open(KeyName, provider);
}
return CngKey.Create(
CngAlgorithm.Rsa,
KeyName,
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,
});
}
}