using System.Runtime.InteropServices; using System.Runtime.Versioning; namespace DodoSSH.Client.App.Platform; /// /// The pieces of CoreFoundation and Security.framework needs. /// /// /// /// Separated from the store itself because it is a different kind of code with a different kind of /// review: nothing here makes a decision, and everything here is a translation of a C declaration that /// is either right or wrong. Mixing the two would mean the security argument in /// had to be read past two hundred lines of marshalling to find. /// /// /// Every Create or Copy returns an object this process owns. That is CoreFoundation's Create /// Rule, and it is the thing here that goes wrong silently: the enclave key handle is small, so a leak /// shows up as nothing at all until a long-running process has done a few thousand unlocks. /// exists so ownership is tracked by construction rather than by /// remembering, and every function below that returns a handle says whether it is owned. /// /// /// The integer widths are the part worth checking against the headers rather than skimming. /// CFIndex, CFOptionFlags and CFNumberType are all pointer-width on a 64-bit Mac, /// not 32-bit, and getting one wrong does not fail cleanly — it shifts every argument after it, so the /// call receives plausible rubbish and returns a parameter error that names nothing. /// /// [SupportedOSPlatform("macos")] internal static partial class MacSecurity { internal const string SecurityFramework = "/System/Library/Frameworks/Security.framework/Security"; internal const string CoreFoundation = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"; /// /// The access control flags SecAccessControlCreateWithFlags takes. /// /// /// ulong because the parameter is a CFOptionFlags, which is an unsigned long. /// Only the two flags that are used are listed; the full set is large, and copying it in would /// invite somebody to reach for one without reading what it does to the prompt — Biometry /// alone, for instance, leaves a Mac with no Touch ID unable to unlock at all rather than falling /// back to the login password. /// [Flags] internal enum AccessControlFlags : ulong { /// /// Touch ID if the machine has it, the login password if not. /// /// /// The forgiving one, deliberately. BiometryCurrentSet would additionally invalidate the /// key whenever a fingerprint is added or removed, which sounds stricter and here buys nothing: /// this key wraps a device key whose loss already means "ask for the passphrase", so the only /// effect would be users being sent back to their passphrase by an unrelated Settings change /// they would never connect to it. /// UserPresence = 1ul << 0, /// Required for any key that lives in the Secure Enclave. PrivateKeyUsage = 1ul << 30, } /// The CFNumberType code for a 32-bit int, from CFNumber.h. internal const long CFNumberIntType = 9; /// errSecSuccess. internal const int Success = 0; /// errSecItemNotFound, which is an answer rather than a failure. internal const int ItemNotFound = -25300; // ---- CoreFoundation ------------------------------------------------------------------------------ /// Releases an owned handle. [LibraryImport(CoreFoundation)] internal static partial void CFRelease(IntPtr handle); /// Copies bytes into a new CFData. Owned. [LibraryImport(CoreFoundation)] internal static partial IntPtr CFDataCreate(IntPtr allocator, IntPtr bytes, nint length); [LibraryImport(CoreFoundation)] internal static partial IntPtr CFDataGetBytePtr(IntPtr data); [LibraryImport(CoreFoundation)] internal static partial nint CFDataGetLength(IntPtr data); /// Boxes a value as a CFNumber. Owned. [LibraryImport(CoreFoundation)] internal static partial IntPtr CFNumberCreate(IntPtr allocator, nint theType, IntPtr valuePtr); /// Builds an immutable dictionary. Owned. /// /// /// The key and value arrays are passed as raw pointers to memory the caller pins, rather than as /// managed arrays. Source-generated interop wants an explicit element count for a marshalled array, /// and supplying one here would mean stating the length twice — once for the marshaller and once as /// — which is exactly the pair that drifts. /// /// /// The two callback tables are what make the dictionary retain its keys and values, which is why /// they are passed rather than left null: with null callbacks the dictionary stores raw pointers and /// keeps nothing alive, and the resulting use-after-free is intermittent by nature. /// /// [LibraryImport(CoreFoundation)] internal static partial IntPtr CFDictionaryCreate( IntPtr allocator, IntPtr keys, IntPtr values, nint numValues, IntPtr keyCallBacks, IntPtr valueCallBacks); // ---- Security.framework -------------------------------------------------------------------------- /// Builds the access policy a Secure Enclave key is created under. Owned. [LibraryImport(SecurityFramework)] internal static partial IntPtr SecAccessControlCreateWithFlags( IntPtr allocator, IntPtr protection, AccessControlFlags flags, out IntPtr error); /// Creates a key pair from an attribute dictionary. Owned. [LibraryImport(SecurityFramework)] internal static partial IntPtr SecKeyCreateRandomKey(IntPtr parameters, out IntPtr error); /// The public half of a key. Owned. /// /// Available even for an enclave key, and that asymmetry is the whole reason this design works: the /// public half is an ordinary key this process can hold and use, while the private half is a handle /// to something inside the enclave that never becomes bytes. So sealing is silent and unsealing is /// the thing the user is asked about. /// [LibraryImport(SecurityFramework)] internal static partial IntPtr SecKeyCopyPublicKey(IntPtr key); /// Encrypts with a public key. Owned. [LibraryImport(SecurityFramework)] internal static partial IntPtr SecKeyCreateEncryptedData( IntPtr key, IntPtr algorithm, IntPtr plaintext, out IntPtr error); /// Decrypts with a private key, prompting for whatever guards it. Owned. [LibraryImport(SecurityFramework)] internal static partial IntPtr SecKeyCreateDecryptedData( IntPtr key, IntPtr algorithm, IntPtr ciphertext, out IntPtr error); /// Finds a keychain item. The out handle is owned when the result is . [LibraryImport(SecurityFramework)] internal static partial int SecItemCopyMatching(IntPtr query, out IntPtr result); /// Deletes every keychain item matching the query. [LibraryImport(SecurityFramework)] internal static partial int SecItemDelete(IntPtr query); // ---- The framework constants --------------------------------------------------------------------- /// /// Reads one of a framework's global CFString constants, or zero if it is not exported. /// /// /// /// The keys these dictionaries take are not strings this code may spell for itself. They are /// pointer-comparable constants exported by the framework, and a CFString built here with the same /// characters is a different object — the lookups would miss and the call would fail with a /// parameter error naming nothing. /// /// /// Dereferenced once, because the exported symbol is the variable rather than its value. /// TryGetExport answers the address of the global; the CFStringRef is what that address /// holds. Missing the indirection produces a pointer that is stable, plausible and wrong, which is /// the worst of the three available outcomes. /// /// /// Zero on a missing symbol rather than an exception, because the caller's answer to every failure /// is the same one — report the store unavailable and let unlock ask for the passphrase — and a /// constant that has been renamed by a future macOS should reach that answer rather than a crash. /// /// internal static IntPtr Constant(IntPtr library, string symbol) => NativeLibrary.TryGetExport(library, symbol, out var address) ? Marshal.ReadIntPtr(address) : IntPtr.Zero; } /// /// Releases every CoreFoundation handle put into it, in reverse order, exactly once. /// /// /// /// The alternative is a try/finally per handle, and the operations here need six or seven at a time — a /// dictionary holding a nested dictionary holding an access control object holding a CFData tag. Finallys /// nested that deep stop being read, and a handle released twice is a crash rather than a leak. /// /// /// returns what it was given, so a handle can be tracked in the same expression that /// produces it and the call sites read as ordinary code. /// /// [SupportedOSPlatform("macos")] internal sealed class CoreFoundationScope : IDisposable { private readonly List owned = []; private bool disposed; /// Takes ownership of a handle and hands it straight back. /// /// Zero is ignored rather than rejected. Every CoreFoundation call here answers zero on failure, so /// accepting it lets a caller track the result in the expression that produces it and check it on /// the next line, instead of writing the check twice. /// internal IntPtr Keep(IntPtr handle) { if (handle != IntPtr.Zero) { owned.Add(handle); } return handle; } public void Dispose() { if (disposed) { return; } disposed = true; // Reverse order, so a container is released before the things it retains. CoreFoundation does not // require it — retain counts make the order irrelevant — but it keeps the lifetimes readable in a // debugger, where a released container that still lists its contents is a confusing thing to meet. for (var i = owned.Count - 1; i >= 0; i--) { MacSecurity.CFRelease(owned[i]); } owned.Clear(); } }