diff --git a/src/DodoSSH.Client.App/Platform/DeviceKeyNames.cs b/src/DodoSSH.Client.App/Platform/DeviceKeyNames.cs
new file mode 100644
index 0000000..dca1e2c
--- /dev/null
+++ b/src/DodoSSH.Client.App/Platform/DeviceKeyNames.cs
@@ -0,0 +1,48 @@
+using System.Security.Cryptography;
+using System.Text;
+using DodoSSH.Client.Session;
+
+namespace DodoSSH.Client.App.Platform;
+
+///
+/// What a platform key store calls the hardware key behind one profile's device unlock.
+///
+///
+///
+/// ◆ One key per profile, because the key outlives the file and is shared by name. The CNG key and
+/// the Secure Enclave keychain item are found by name in a store that belongs to the user, not to the
+/// profile directory. While every profile used the same name, the release build and the nightly — which
+/// keeps apart precisely so neither can damage the other — shared one
+/// key: signing out of either deleted it, and the other's device unlock silently stopped working.
+///
+///
+/// The default profile keeps the name it has always had, so an existing install's sealed key still
+/// opens. Every other profile gets that name with a digest of its directory on the end — a digest rather
+/// than the directory's own name, because a portable copy can live in any folder and two such folders can
+/// end in the same name.
+///
+///
+internal static class DeviceKeyNames
+{
+ /// The name of the key behind ' device unlock.
+ /// The profile.
+ /// The name every profile used before names were per profile.
+ internal static string For(ClientPaths paths, string legacy)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+
+ var directory = Normalise(paths.DataDirectory);
+
+ if (string.Equals(directory, Normalise(ClientPaths.Default.DataDirectory), StringComparison.OrdinalIgnoreCase))
+ {
+ return legacy;
+ }
+
+ var digest = SHA256.HashData(Encoding.UTF8.GetBytes(directory.ToUpperInvariant()));
+
+ return $"{legacy}.{Convert.ToHexStringLower(digest.AsSpan(0, 8))}";
+ }
+
+ private static string Normalise(string directory) =>
+ Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
+}
diff --git a/src/DodoSSH.Client.App/Platform/MacDeviceKeyStore.cs b/src/DodoSSH.Client.App/Platform/MacDeviceKeyStore.cs
index 2403c3b..e0f44c4 100644
--- a/src/DodoSSH.Client.App/Platform/MacDeviceKeyStore.cs
+++ b/src/DodoSSH.Client.App/Platform/MacDeviceKeyStore.cs
@@ -78,11 +78,21 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
private readonly ClientPaths paths;
+ /// This profile's own enclave key; see .
+ ///
+ /// for the default profile and a tag of its own for every other, so that signing
+ /// out of one installation cannot delete the key another's Touch ID unlock is sealed to. A profile
+ /// registered before this is still sealed to the shared tag and is read from there until it registers
+ /// again; it is never deleted from a profile that does not own it.
+ ///
+ private readonly string keyTag;
+
/// Creates the store.
public MacDeviceKeyStore(ClientPaths paths)
{
ArgumentNullException.ThrowIfNull(paths);
this.paths = paths;
+ keyTag = DeviceKeyNames.For(paths, KeyTag);
}
///
@@ -163,7 +173,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
ReadOnlyMemory devicePrivateKey,
CancellationToken cancellationToken)
{
- var sealedKey = Seal(devicePrivateKey.Span)
+ var sealedKey = Seal(devicePrivateKey.Span, keyTag)
?? throw new InvalidOperationException(
"The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one.");
@@ -184,7 +194,8 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
.ConfigureAwait(false);
- return Unseal(sealedKey);
+ // The shared tag only while this profile has no key of its own, so one unlock is one prompt.
+ return Unseal(sealedKey, !string.Equals(keyTag, KeyTag, StringComparison.Ordinal) && !HasKey(keyTag) ? KeyTag : keyTag);
}
///
@@ -202,7 +213,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
// Discarded, and that is deliberate: there is nothing a caller could do about a failure here,
// and the file deleted above is the half that decides whether unlock will try at all. A key
// left in the enclave with no ciphertext to open is inert.
- _ = DeleteKey(symbols, KeyTag);
+ _ = DeleteKey(symbols, keyTag);
}
return ValueTask.CompletedTask;
@@ -212,7 +223,26 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
/// Silent: it uses only the public half. Null on every failure, and the caller's answer to all of
/// them is the same — do not offer a device unlock.
///
- private static byte[]? Seal(ReadOnlySpan devicePrivateKey)
+ /// Whether a key is filed under this tag, asked without using it and so without a prompt.
+ private static bool HasKey(string tag)
+ {
+ try
+ {
+ using var scope = new CoreFoundationScope();
+
+ var symbols = MacSymbols.Resolve();
+
+ return symbols.Complete && FindKey(scope, symbols, tag, prompt: null) != IntPtr.Zero;
+ }
+ catch (Exception exception) when (exception is DllNotFoundException
+ or EntryPointNotFoundException
+ or BadImageFormatException)
+ {
+ return false;
+ }
+ }
+
+ private static byte[]? Seal(ReadOnlySpan devicePrivateKey, string tag)
{
try
{
@@ -227,11 +257,11 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
// Created on first use rather than at registration, so that a device key re-registered after
// a ForgetAsync gets a key again without anything having to notice that it had gone.
- var privateKey = FindKey(scope, symbols, KeyTag, prompt: null);
+ var privateKey = FindKey(scope, symbols, tag, prompt: null);
if (privateKey == IntPtr.Zero)
{
- privateKey = CreateEnclaveKey(scope, symbols, KeyTag);
+ privateKey = CreateEnclaveKey(scope, symbols, tag);
}
if (privateKey == IntPtr.Zero)
@@ -281,7 +311,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
/// on a thread that is also expected to draw the window behind it.
///
///
- private static byte[]? Unseal(byte[] sealedKey)
+ private static byte[]? Unseal(byte[] sealedKey, string tag)
{
try
{
@@ -294,7 +324,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
return null;
}
- var privateKey = FindKey(scope, symbols, KeyTag, ConsentPrompt);
+ var privateKey = FindKey(scope, symbols, tag, ConsentPrompt);
if (privateKey == IntPtr.Zero)
{
diff --git a/src/DodoSSH.Client.App/Platform/WindowsDeviceKeyStore.cs b/src/DodoSSH.Client.App/Platform/WindowsDeviceKeyStore.cs
index 7639534..c88b659 100644
--- a/src/DodoSSH.Client.App/Platform/WindowsDeviceKeyStore.cs
+++ b/src/DodoSSH.Client.App/Platform/WindowsDeviceKeyStore.cs
@@ -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.
+ ///
+ /// 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";
@@ -133,11 +137,22 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
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);
}
///
@@ -182,7 +197,7 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
ReadOnlyMemory 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);
}
///
@@ -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
/// UnlockStatus.DeviceKeyUnavailable.
///
- 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.
///
- 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,
diff --git a/src/DodoSSH.Client.App/Views/DevicePinWindow.axaml b/src/DodoSSH.Client.App/Views/DevicePinWindow.axaml
index 8c6ec8f..c0e713c 100644
--- a/src/DodoSSH.Client.App/Views/DevicePinWindow.axaml
+++ b/src/DodoSSH.Client.App/Views/DevicePinWindow.axaml
@@ -4,7 +4,7 @@
Width="420" SizeToContent="Height"
CanResize="False" ShowInTaskbar="False"
WindowStartupLocation="CenterOwner"
- Background="{StaticResource Surface}"
+ Background="{StaticResource Panel}"
Title="DodoSSH">