using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using DodoSSH.Client.Session;
using static DodoSSH.Client.App.Platform.MacSecurity;
namespace DodoSSH.Client.App.Platform;
///
/// Keeps the device key encrypted to a Secure Enclave key whose use requires the user's presence.
///
///
///
/// The macOS counterpart of , and the same argument holds it up:
/// the consent is enforced by the platform, not by this class. The unwrapping key is generated
/// inside the Secure Enclave and never leaves it — there is no code path, privileged or otherwise, that
/// turns it into bytes — and it is created under an access control requiring
/// , so Touch ID or the login password is a condition of
/// using it. Malware running as the user can ask for a decryption; it cannot answer the prompt,
/// and the attempt is visible.
///
///
/// A store that showed its own prompt and then read a protected file would be trivially bypassed, which
/// is the mistake ADR 0007 originally described and the Windows store's comment corrects. The correction
/// applies here unchanged.
///
///
/// P-256 and ECIES, where Windows uses RSA-OAEP, and the difference is not a preference. The
/// Secure Enclave holds exactly one kind of key: a 256-bit key on the NIST P-256 curve. It will not hold
/// an RSA key at any size. So the wrap is eciesEncryptionCofactorX963SHA256AESGCM — an ephemeral
/// agreement against the enclave's public half, X9.63-KDF to an AES-GCM key, and the ephemeral public
/// key carried in the output. The framework does all of that; what matters here is that the input is 32
/// bytes and there is no size limit worth worrying about.
///
///
/// Sealing is silent and unsealing prompts, which is better than the Windows shape rather than merely
/// different. On Windows, CngKey.Create with ProtectKey raises a dialog at creation as
/// well, because the policy means "protect this key with a PIN" and Windows sets that up there and then.
/// Here works on an enclave key without any prompt, so registering a
/// device shows nothing and only unlock asks. is therefore not user-facing on
/// this platform — but it is still called from where the Windows one has to be, and relying on that
/// difference would make the shared caller platform-specific for no gain.
///
///
/// What this cannot be tested against, and what follows from that. Every method except
/// and the empty case of needs an interactive
/// login session and real enclave hardware, so none can be exercised by an automated test — the same
/// line the Windows store draws. It also means must probe rather than infer:
/// see its remarks for the three ordinary machines that have no usable enclave and must degrade to the
/// passphrase rather than fail at unlock.
///
///
[SupportedOSPlatform("macos")]
public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
{
///
/// The keychain tag this application's enclave key is filed under.
///
///
/// Versioned for the reason the Windows key name is: a future change of curve or wrap algorithm 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 fingerprint had stopped working.
///
/// Prefixed with the bundle identifier because the keychain is shared across every application the
/// user runs, unlike a CNG key name, which is scoped to the user's key store already.
///
private const string KeyTag = "dev.dodotech.dodossh.devicekey.v1";
///
/// Shown in the Touch ID prompt, so it has to read as a sentence to a person.
///
///
/// macOS composes it into "DodoSSH is trying to ...", so this is a verb phrase and not a sentence of
/// its own. The same words the Windows consent dialog uses.
///
private const string ConsentPrompt = "unlock your DodoSSH vault";
private readonly ClientPaths paths;
/// Creates the store.
public MacDeviceKeyStore(ClientPaths paths)
{
ArgumentNullException.ThrowIfNull(paths);
this.paths = paths;
}
///
/// Whether this Mac has a Secure Enclave that will hold a key for this build.
///
///
///
/// Probed by creating a throwaway key and deleting it, rather than by asking whether the hardware
/// exists. Three ordinary situations answer "no" here and would otherwise only be discovered at the
/// moment somebody tried to unlock:
///
///
/// An Intel Mac with no T2. Apple Silicon and T2 machines have an enclave; earlier Intel
/// models do not, and there is no single attribute that says so.
///
///
/// A build that is not code signed. Enclave key creation requires a signing identity, so
/// every dotnet run and every build from an IDE fails here with a missing-entitlement error.
/// That is the correct answer rather than a nuisance: a development build should keep asking for the
/// passphrase, and this is what makes it do so without a platform check somewhere else.
///
///
/// A machine with no login password set. has
/// nothing to demand, and the framework refuses the access control object rather than silently
/// creating a key anybody could use.
///
///
/// The probe uses its own tag and no UI policy, so nothing prompts and nothing collides with the
/// real key. It is deleted immediately; a probe key left behind would accumulate one per launch.
///
///
internal static bool IsSupported()
{
try
{
var probe = $"{KeyTag}.probe.{Guid.CreateVersion7():N}";
using var scope = new CoreFoundationScope();
var symbols = MacSymbols.Resolve();
if (!symbols.Complete)
{
return false;
}
var key = CreateEnclaveKey(scope, symbols, probe);
if (key == IntPtr.Zero)
{
return false;
}
// Discarded deliberately. The question this method answers is whether the enclave will make a
// key, and it demonstrably just did; a failure to clean the probe up afterwards leaves one
// stray keychain item and does not make the answer no.
_ = DeleteKey(symbols, probe);
return true;
}
catch (Exception exception) when (exception is DllNotFoundException
or EntryPointNotFoundException
or BadImageFormatException)
{
// A macOS without these frameworks is not a thing that exists, so this is really the guard
// for the case that does: a future release renaming or removing one of them. The answer is
// the same as for hardware that is absent — no device key, ask for the passphrase.
return false;
}
}
///
public ValueTask IsAvailableAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(IsSupported());
///
public async ValueTask SaveAsync(
ReadOnlyMemory devicePrivateKey,
CancellationToken cancellationToken)
{
var sealedKey = Seal(devicePrivateKey.Span)
?? throw new InvalidOperationException(
"The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one.");
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);
return Unseal(sealedKey);
}
///
public ValueTask ForgetAsync(CancellationToken cancellationToken)
{
if (File.Exists(paths.DeviceKeyFile))
{
File.Delete(paths.DeviceKeyFile);
}
var symbols = MacSymbols.Resolve();
if (symbols.Complete)
{
// 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);
}
return ValueTask.CompletedTask;
}
///
/// 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)
{
try
{
using var scope = new CoreFoundationScope();
var symbols = MacSymbols.Resolve();
if (!symbols.Complete)
{
return null;
}
// 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);
if (privateKey == IntPtr.Zero)
{
privateKey = CreateEnclaveKey(scope, symbols, KeyTag);
}
if (privateKey == IntPtr.Zero)
{
return null;
}
var publicKey = scope.Keep(SecKeyCopyPublicKey(privateKey));
if (publicKey == IntPtr.Zero)
{
return null;
}
var plaintext = Data(scope, devicePrivateKey);
if (plaintext == IntPtr.Zero)
{
return null;
}
var ciphertext = scope.Keep(
SecKeyCreateEncryptedData(publicKey, symbols.EciesAlgorithm, plaintext, out var error));
scope.Keep(error);
return ciphertext == IntPtr.Zero ? null : ToArray(ciphertext);
}
catch (Exception exception) when (exception is DllNotFoundException
or EntryPointNotFoundException
or BadImageFormatException)
{
return null;
}
}
///
///
/// 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 or let the prompt time out, the enclave may have
/// invalidated it after the login password was reset, 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
/// are worth telling apart here — see UnlockStatus.DeviceKeyUnavailable.
///
///
/// Blocking, and it blocks on a person. The prompt is modal to the application, so this must not run
/// on a thread that is also expected to draw the window behind it.
///
///
private static byte[]? Unseal(byte[] sealedKey)
{
try
{
using var scope = new CoreFoundationScope();
var symbols = MacSymbols.Resolve();
if (!symbols.Complete)
{
return null;
}
var privateKey = FindKey(scope, symbols, KeyTag, ConsentPrompt);
if (privateKey == IntPtr.Zero)
{
return null;
}
var ciphertext = Data(scope, sealedKey);
if (ciphertext == IntPtr.Zero)
{
return null;
}
var plaintext = scope.Keep(
SecKeyCreateDecryptedData(privateKey, symbols.EciesAlgorithm, ciphertext, out var error));
scope.Keep(error);
return plaintext == IntPtr.Zero ? null : ToArray(plaintext);
}
catch (Exception exception) when (exception is DllNotFoundException
or EntryPointNotFoundException
or BadImageFormatException)
{
return null;
}
}
///
/// Generates a key inside the Secure Enclave, filed under . Owned by the scope.
///
///
///
/// The attribute dictionary is the whole security decision, so it is worth reading rather than
/// pattern-matching. TokenID = SecureEnclave is what puts the private half in hardware;
/// without it this silently generates an ordinary software key that behaves identically in every
/// visible way and protects nothing.
///
///
/// AccessibleWhenUnlockedThisDeviceOnly rather than any of the migratable classes, because a
/// device key that could be restored onto another machine from a backup would no longer mean "this
/// machine". The enclave already makes that impossible; saying it as well means the intent survives
/// a future change of storage.
///
///
/// UseDataProtectionKeychain is the macOS-specific one and the easiest to omit. Without it,
/// macOS routes this to the older file-based keychain, which does not understand access control
/// objects or the enclave, and the call fails with a parameter error that says nothing about the
/// missing key.
///
///
private static IntPtr CreateEnclaveKey(CoreFoundationScope scope, MacSymbols symbols, string tag)
{
var access = scope.Keep(SecAccessControlCreateWithFlags(
IntPtr.Zero,
symbols.AccessibleWhenUnlockedThisDeviceOnly,
AccessControlFlags.PrivateKeyUsage | AccessControlFlags.UserPresence,
out var accessError));
scope.Keep(accessError);
if (access == IntPtr.Zero)
{
return IntPtr.Zero;
}
var privateAttrs = Dictionary(
scope,
[symbols.AttrIsPermanent, symbols.AttrApplicationTag, symbols.AttrAccessControl],
[symbols.True, TagData(scope, tag), access]);
if (privateAttrs == IntPtr.Zero)
{
return IntPtr.Zero;
}
var keySize = Number(scope, 256);
var parameters = Dictionary(
scope,
[
symbols.AttrKeyType,
symbols.AttrKeySizeInBits,
symbols.AttrTokenId,
symbols.UseDataProtectionKeychain,
symbols.PrivateKeyAttrs,
],
[
symbols.KeyTypeEcSecPrimeRandom,
keySize,
symbols.TokenIdSecureEnclave,
symbols.True,
privateAttrs,
]);
if (parameters == IntPtr.Zero)
{
return IntPtr.Zero;
}
var key = scope.Keep(SecKeyCreateRandomKey(parameters, out var error));
scope.Keep(error);
return key;
}
///
/// Looks the enclave key up by tag. Owned by the scope; zero when there is none.
///
///
/// is attached here and consumed later: the lookup itself does not raise
/// anything, because a handle to an enclave key is not a use of it. The words reach the user at the
/// decrypt, which is the operation the access control actually guards.
///
/// UseOperationPrompt is deprecated in favour of an LAContext, and is used anyway. An
/// LAContext would mean binding LocalAuthentication as well for one string, and the deprecated key
/// still works; the day it stops, this call fails and the store degrades to the passphrase, which is
/// the failure this whole class is built to degrade into.
///
private static IntPtr FindKey(CoreFoundationScope scope, MacSymbols symbols, string tag, string? prompt)
{
List keys =
[
symbols.Class,
symbols.AttrApplicationTag,
symbols.AttrKeyType,
symbols.UseDataProtectionKeychain,
symbols.ReturnRef,
];
List values =
[
symbols.ClassKey,
TagData(scope, tag),
symbols.KeyTypeEcSecPrimeRandom,
symbols.True,
symbols.True,
];
if (prompt is not null)
{
keys.Add(symbols.UseOperationPrompt);
values.Add(scope.Keep(CFString(prompt)));
}
var query = Dictionary(scope, [.. keys], [.. values]);
if (query == IntPtr.Zero)
{
return IntPtr.Zero;
}
var status = SecItemCopyMatching(query, out var result);
// errSecItemNotFound is the ordinary answer on a machine that has never registered a device, and
// it is not distinguished from any other failure for the reason the class remarks give.
return status == Success ? scope.Keep(result) : IntPtr.Zero;
}
/// Removes the key with this tag from the keychain.
/// Whether the keychain now has no key under this tag.
///
/// ItemNotFound counts as success, and that is the common case rather than an edge: it is
/// what a machine that never registered a device answers, and what the second of two
/// calls answers. Treating it as a failure would make forgetting a device
/// twice report a problem that does not exist.
///
private static bool DeleteKey(MacSymbols symbols, string tag)
{
using var scope = new CoreFoundationScope();
var query = Dictionary(
scope,
[symbols.Class, symbols.AttrApplicationTag, symbols.UseDataProtectionKeychain],
[symbols.ClassKey, TagData(scope, tag), symbols.True]);
if (query == IntPtr.Zero)
{
return false;
}
var status = SecItemDelete(query);
return status is Success or ItemNotFound;
}
// ---- Small CoreFoundation conveniences ---------------------------------------------------------
///
/// The arrays are pinned for the duration of the call and not beyond it, which is correct because
/// CFDictionaryCreate copies them: the dictionary retains each key and value, and never reads
/// the arrays again.
///
private static IntPtr Dictionary(CoreFoundationScope scope, IntPtr[] keys, IntPtr[] values)
{
// A zero anywhere means one of the constants did not resolve or an earlier allocation failed.
// Passing it on produces a dictionary with a null key, which CFDictionaryCreate does not reject
// — it crashes inside the callback table instead.
if (Array.IndexOf(keys, IntPtr.Zero) >= 0 || Array.IndexOf(values, IntPtr.Zero) >= 0)
{
return IntPtr.Zero;
}
var symbols = MacSymbols.Resolve();
unsafe
{
fixed (IntPtr* keyPtr = keys)
fixed (IntPtr* valuePtr = values)
{
return scope.Keep(CFDictionaryCreate(
IntPtr.Zero,
(IntPtr)keyPtr,
(IntPtr)valuePtr,
keys.Length,
symbols.TypeDictionaryKeyCallBacks,
symbols.TypeDictionaryValueCallBacks));
}
}
}
/// Copies bytes into a CFData. Owned by the scope.
///
/// The pin lasts only as long as the call, which is correct: CFDataCreate copies, so the
/// CFData does not reference this memory afterwards. CFDataCreateWithBytesNoCopy would not,
/// and is not used for exactly that reason — it would hand the framework a pointer into the managed
/// heap and rely on the object staying where the collector first put it.
///
private static IntPtr Data(CoreFoundationScope scope, ReadOnlySpan bytes)
{
unsafe
{
fixed (byte* pointer = bytes)
{
return scope.Keep(CFDataCreate(IntPtr.Zero, (IntPtr)pointer, bytes.Length));
}
}
}
///
/// UTF-8 rather than any other encoding, and it only has to be consistent with itself: the tag is an
/// opaque blob the keychain matches byte for byte, so what matters is that a lookup encodes it the
/// same way the creation did. It is written once, here, for exactly that reason.
///
private static IntPtr TagData(CoreFoundationScope scope, string tag) =>
Data(scope, Encoding.UTF8.GetBytes(tag));
private static IntPtr Number(CoreFoundationScope scope, int value)
{
unsafe
{
return scope.Keep(CFNumberCreate(IntPtr.Zero, (nint)CFNumberIntType, (IntPtr)(&value)));
}
}
/// Builds a CFString from a managed string. Owned, so the caller tracks it.
///
/// Built explicitly rather than left to the marshaller, because these calls take a
/// CFStringRef and not a C string — the runtime's default marshalling would hand over a
/// char*, which CoreFoundation reads as an object pointer and follows into nothing.
///
private static IntPtr CFString(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
unsafe
{
fixed (byte* pointer = bytes)
{
// kCFStringEncodingUTF8 is 0x08000100, spelled out rather than named because it is the
// only encoding constant this file uses.
return CFStringCreateWithBytes(IntPtr.Zero, (IntPtr)pointer, bytes.Length, 0x08000100, false);
}
}
}
[LibraryImport(CoreFoundation)]
private static partial IntPtr CFStringCreateWithBytes(
IntPtr allocator,
IntPtr bytes,
nint numBytes,
uint encoding,
[MarshalAs(UnmanagedType.U1)] bool isExternalRepresentation);
private static byte[] ToArray(IntPtr data)
{
var length = (int)CFDataGetLength(data);
var pointer = CFDataGetBytePtr(data);
if (length <= 0 || pointer == IntPtr.Zero)
{
return [];
}
var result = new byte[length];
Marshal.Copy(pointer, result, 0, length);
return result;
}
}