Give each profile its own device key, and make the Linux PIN window able to open at all

- The Windows CNG key and the macOS keychain tag were one name for every profile, so signing out of
  the nightly deleted the key the release build's device unlock was sealed to. DeviceKeyNames keeps the
  existing name for the default profile and gives every other profile its own; sign-out deletes only
  its own key, and a profile sealed before this keeps reading the shared one.
- DevicePinWindow named two palette resources that do not exist and loaded itself with
  AvaloniaXamlLoader.Load, which never assigns the named fields, so it threw on construction and no
  PIN prompt was ever shown. A layout test now builds it against the real palette.
This commit is contained in:
jaap-jan committed 2026-10-02 13:16:48 +02:00
1 parent 2ff9dff560
commit 0e8fe4fc80
7 files changed
+213 -22

No files matched your search

@@ -123,6 +123,10 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
/// 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";
@@ -133,11 +137,22 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
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>
@@ -182,7 +197,7 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
ReadOnlyMemory<byte> devicePrivateKey,
CancellationToken cancellationToken)
{
using var key = OpenOrCreate();
using var key = OpenOrCreate(keyName);
using var rsa = new RSACng(key);
var sealedKey = rsa.Encrypt(devicePrivateKey.Span, RSAEncryptionPadding.OaepSHA256);
@@ -204,7 +219,13 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
.ConfigureAwait(false);
return Unseal(sealedKey);
// 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 />
@@ -215,9 +236,11 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
File.Delete(paths.DeviceKeyFile);
}
if (CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
// 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));
using var key = CngKey.Open(keyName, new CngProvider(PlatformProvider));
key.Delete();
}
@@ -231,16 +254,16 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
/// 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)
private static byte[]? Unseal(byte[] sealedKey, string name)
{
try
{
if (!CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
if (!CngKey.Exists(name, new CngProvider(PlatformProvider)))
{
return null;
}
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
using var key = CngKey.Open(name, new CngProvider(PlatformProvider));
using var rsa = new RSACng(key);
return rsa.Decrypt(sealedKey, RSAEncryptionPadding.OaepSHA256);
@@ -256,18 +279,18 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
/// 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()
private static CngKey OpenOrCreate(string name)
{
var provider = new CngProvider(PlatformProvider);
if (CngKey.Exists(KeyName, provider))
if (CngKey.Exists(name, provider))
{
return CngKey.Open(KeyName, provider);
return CngKey.Open(name, provider);
}
return CngKey.Create(
CngAlgorithm.Rsa,
KeyName,
name,
new CngKeyCreationParameters
{
Provider = provider,