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

@@ -78,11 +78,21 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
private readonly ClientPaths paths;
/// <summary>This profile's own enclave key; see <see cref="DeviceKeyNames"/>.</summary>
/// <remarks>
/// <see cref="KeyTag"/> 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.
/// </remarks>
private readonly string keyTag;
/// <summary>Creates the store.</summary>
public MacDeviceKeyStore(ClientPaths paths)
{
ArgumentNullException.ThrowIfNull(paths);
this.paths = paths;
keyTag = DeviceKeyNames.For(paths, KeyTag);
}
/// <summary>
@@ -163,7 +173,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
ReadOnlyMemory<byte> 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);
}
/// <inheritdoc />
@@ -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.
/// </remarks>
private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey)
/// <summary>Whether a key is filed under this tag, asked without using it and so without a prompt.</summary>
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<byte> 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.
/// </para>
/// </remarks>
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)
{