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; } }