// Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System.Reflection; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Text; using System.Web; using Duplicati.Library.Interface; using Duplicati.Library.Utility; namespace Duplicati.Library.SecretProvider; /// /// Implementation of a secret provider that reads secrets from the MacOS keychain /// [SupportedOSPlatform("macos")] public class MacOSKeyChainProvider : ISecretProvider { /// public string Key => "keychain"; /// public string DisplayName => Strings.MacOSKeyChainProvider.DisplayName; /// public string Description => Strings.MacOSKeyChainProvider.Description; /// public Task IsSupported(CancellationToken cancellationToken) => Task.FromResult(OperatingSystem.IsMacOS()); /// public bool IsSetSupported => true; /// /// The type of password to get /// private enum PasswordType { /// /// A generic password /// Generic, /// /// An internet password /// Internet } /// /// The settings for the keychain /// private class KeyChainSettings : ICommandLineArgumentMapper { /// /// The default service name /// private const string ServiceDefault = "com.duplicati.secrets"; /// /// The default account name /// private const string AccountDefault = "Duplicati"; /// /// The service name /// public string? Service { get; set; } = ServiceDefault; /// /// The account name /// public string? Account { get; set; } = AccountDefault; /// /// Gets or sets a value indicating whether to use internet passwords as opposed to generic passwords /// public PasswordType Type { get; set; } = PasswordType.Generic; /// /// Gets the command line argument description for a member /// /// The name of the member /// The command line argument description public static CommandLineArgumentDescriptionAttribute? GetCommandLineArgumentDescription(string name) => name switch { nameof(Service) => new CommandLineArgumentDescriptionAttribute { Name = "service", ShortDescription = Strings.MacOSKeyChainProvider.ServiceDescriptionShort, LongDescription = Strings.MacOSKeyChainProvider.ServiceDescriptionLong, DefaultValue = ServiceDefault }, nameof(Account) => new CommandLineArgumentDescriptionAttribute { Name = "account", ShortDescription = Strings.MacOSKeyChainProvider.AccountDescriptionShort, LongDescription = Strings.MacOSKeyChainProvider.AccountDescriptionLong, DefaultValue = AccountDefault }, nameof(Type) => new CommandLineArgumentDescriptionAttribute { Name = "type", Type = CommandLineArgument.ArgumentType.Enumeration, ShortDescription = Strings.MacOSKeyChainProvider.TypeDescriptionShort, LongDescription = Strings.MacOSKeyChainProvider.TypeDescriptionLong }, _ => null }; /// CommandLineArgumentDescriptionAttribute? ICommandLineArgumentMapper.GetCommandLineArgumentDescription(MemberInfo mi) => GetCommandLineArgumentDescription(mi.Name); } /// /// The settings for the keychain; null if not initialized /// private KeyChainSettings? _settings; /// public IList SupportedCommands => CommandLineArgumentMapper.MapArguments(new KeyChainSettings()).ToList(); /// public Task InitializeAsync(System.Uri config, CancellationToken cancellationToken) { if (!OperatingSystem.IsMacOS()) throw new PlatformNotSupportedException("The MacOSKeyChainProvider is only supported on macOS"); var args = HttpUtility.ParseQueryString(config.Query); _settings = CommandLineArgumentMapper.ApplyArguments(new KeyChainSettings(), args); return Task.CompletedTask; } /// public async Task> ResolveSecretsAsync(IEnumerable keys, CancellationToken cancellationToken) { if (_settings is null) throw new InvalidOperationException("The MacOSKeyChainProvider has not been initialized"); var result = new Dictionary(); foreach (var key in keys) { cancellationToken.ThrowIfCancellationRequested(); var value = await GetStringAsync(key, _settings, cancellationToken).ConfigureAwait(false); result[key] = value; } return result; } /// public async Task SetSecretAsync(string key, string value, bool overwrite, CancellationToken cancellationToken) { if (!OperatingSystem.IsMacOS()) throw new PlatformNotSupportedException("The MacOSKeyChainProvider is only supported on MacOS"); if (_settings is null) throw new InvalidOperationException("The MacOSKeyChainProvider has not been initialized"); cancellationToken.ThrowIfCancellationRequested(); await SetStringAsync(key, value, overwrite, _settings, cancellationToken).ConfigureAwait(false); } /// /// Native methods for accessing the MacOS keychain /// private static class KeychainNative { /// /// The path to the Security framework /// private const string SecurityLib = "/System/Library/Frameworks/Security.framework/Security"; /// /// The path to the CoreFoundation framework /// private const string CoreFoundationLib = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"; /// /// Success status code /// public const int errSecSuccess = 0; /// /// Duplicate item status code /// public const int errSecDuplicateItem = -25299; // Classic exact-match APIs (fast path) /// /// Finds a generic password item in the keychain. /// /// The keychain to search, or null for default. /// Length of the service name. /// The service name. /// Length of the account name. /// The account name. /// Output: length of the password data. /// Output: pointer to the password data. /// Output: reference to the keychain item. /// Status code; 0 for success. [DllImport(SecurityLib)] internal static extern int SecKeychainFindGenericPassword( IntPtr keychain, uint serviceNameLength, byte[] serviceName, uint accountNameLength, byte[] accountName, out uint passwordLength, out IntPtr passwordData, out IntPtr itemRef); /// /// Finds an internet password item in the keychain. /// /// The keychain to search, or null for default. /// Length of the server name. /// The server name. /// Length of the security domain. /// The security domain. /// Length of the account name. /// The account name. /// Length of the path. /// The path. /// The port number. /// The protocol type. /// The authentication type. /// Output: length of the password data. /// Output: pointer to the password data. /// Output: reference to the keychain item. /// Status code; 0 for success. [DllImport(SecurityLib)] internal static extern int SecKeychainFindInternetPassword( IntPtr keychain, uint serverNameLength, byte[] serverName, uint securityDomainLength, byte[] securityDomain, uint accountNameLength, byte[] accountName, uint pathLength, byte[] path, ushort port, int protocol, int authType, out uint passwordLength, out IntPtr passwordData, out IntPtr itemRef); /// /// Modifies the attributes and data of a keychain item. /// /// Reference to the keychain item. /// List of attributes to modify, or null. /// Length of the data. /// The new data. /// Status code; 0 for success. [DllImport(SecurityLib)] internal static extern int SecKeychainItemModifyAttributesAndData( IntPtr itemRef, IntPtr attrList, uint length, byte[] data); /// /// Frees the memory allocated for keychain item content. /// /// The attribute list to free, or null. /// The data to free. /// Status code; 0 for success. [DllImport(SecurityLib)] internal static extern int SecKeychainItemFreeContent(IntPtr attrList, IntPtr data); // Modern SecItem* APIs (label-aware add/update + label-only lookup) /// /// Copies matching items from the keychain. /// /// Dictionary containing the query parameters. /// Output: the matching item or items. /// Status code; 0 for success. [DllImport(SecurityLib)] internal static extern int SecItemCopyMatching(IntPtr query, out IntPtr result); /// /// Adds an item to the keychain. /// /// Dictionary containing the item attributes. /// Output: reference to the added item. /// Status code; 0 for success. [DllImport(SecurityLib)] internal static extern int SecItemAdd(IntPtr attributes, out IntPtr result); /// /// Updates an item in the keychain. /// /// Dictionary identifying the item to update. /// Dictionary containing the attributes to update. /// Status code; 0 for success. [DllImport(SecurityLib)] internal static extern int SecItemUpdate(IntPtr query, IntPtr attributesToUpdate); // CF helpers /// /// Creates a mutable CoreFoundation dictionary. /// /// The allocator to use, or null for default. /// Initial capacity of the dictionary. /// Callbacks for key operations. /// Callbacks for value operations. /// Pointer to the created dictionary. [DllImport(CoreFoundationLib)] internal static extern IntPtr CFDictionaryCreateMutable( IntPtr allocator, nint capacity, IntPtr keyCallBacks, IntPtr valueCallBacks); /// /// Sets a value in a CoreFoundation dictionary. /// /// The dictionary. /// The key. /// The value. [DllImport(CoreFoundationLib)] internal static extern void CFDictionarySetValue(IntPtr dict, IntPtr key, IntPtr value); /// /// Creates a CoreFoundation string from a C string. /// /// The allocator to use, or null for default. /// The C string bytes. /// The string encoding. /// Pointer to the created string. [DllImport(CoreFoundationLib)] internal static extern IntPtr CFStringCreateWithCString( IntPtr alloc, byte[] cStr, uint encoding); /// /// Creates CoreFoundation data from a byte array. /// /// The allocator to use, or null for default. /// The byte array. /// The length of the data. /// Pointer to the created data. [DllImport(CoreFoundationLib)] internal static extern IntPtr CFDataCreate( IntPtr allocator, byte[] bytes, nint length); /// /// Gets the length of CoreFoundation data. /// /// The data object. /// The length of the data. [DllImport(CoreFoundationLib)] internal static extern nint CFDataGetLength(IntPtr data); /// /// Gets a pointer to the bytes of CoreFoundation data. /// /// The data object. /// Pointer to the byte data. [DllImport(CoreFoundationLib)] internal static extern IntPtr CFDataGetBytePtr(IntPtr data); /// /// Releases a CoreFoundation object. /// /// The object to release. [DllImport(CoreFoundationLib)] internal static extern void CFRelease(IntPtr cf); /// /// UTF-8 encoding constant for CoreFoundation strings. /// internal const uint kCFStringEncodingUTF8 = 0x08000100; // Constant pointers exported by Security/CoreFoundation, loaded via dlsym /// /// Path to libSystem (for dlopen/dlsym). /// private const string LibSystem = "/usr/lib/libSystem.B.dylib"; /// /// Loads a dynamic library. /// /// The path to the library. /// The loading mode. /// Handle to the loaded library. [DllImport(LibSystem, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] private static extern IntPtr dlopen(string path, int mode); /// /// Resolves a symbol from a dynamic library. /// /// Handle to the loaded library. /// The symbol to resolve. /// Pointer to the resolved symbol. [DllImport(LibSystem, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] private static extern IntPtr dlsym(IntPtr handle, string symbol); /// /// Closes a dynamic library. /// /// Handle to the loaded library. /// Zero on success. [DllImport(LibSystem, CallingConvention = CallingConvention.Cdecl)] private static extern int dlclose(IntPtr handle); private static readonly IntPtr _securityHandle; private static readonly IntPtr _coreFoundationHandle; /// /// Key for the class attribute in keychain queries. /// internal static readonly IntPtr SecClass; /// /// Value for generic password class. /// internal static readonly IntPtr SecClassGenericPassword; /// /// Value for internet password class. /// internal static readonly IntPtr SecClassInternetPassword; /// /// Key for the service attribute. /// internal static readonly IntPtr SecAttrService; /// /// Key for the server attribute. /// internal static readonly IntPtr SecAttrServer; /// /// Key for the account attribute. /// internal static readonly IntPtr SecAttrAccount; /// /// Key for the label attribute. /// internal static readonly IntPtr SecAttrLabel; /// /// Key for the value data. /// internal static readonly IntPtr SecValueData; /// /// Key to specify returning data in queries. /// internal static readonly IntPtr SecReturnData; /// /// Key for match limit in queries. /// internal static readonly IntPtr SecMatchLimit; /// /// Value for limiting matches to one. /// internal static readonly IntPtr SecMatchLimitOne; /// /// CoreFoundation true boolean value. /// internal static readonly IntPtr CFBooleanTrue; /// /// Static constructor to load native libraries and resolve constant symbols. /// static KeychainNative() { const int RTLD_LAZY = 0x1; _securityHandle = dlopen(SecurityLib, RTLD_LAZY); _coreFoundationHandle = dlopen(CoreFoundationLib, RTLD_LAZY); if (_securityHandle == IntPtr.Zero || _coreFoundationHandle == IntPtr.Zero) throw new Exception("Failed to load native Security/CoreFoundation frameworks"); SecClass = GetSymbol(_securityHandle, "kSecClass"); SecClassGenericPassword = GetSymbol(_securityHandle, "kSecClassGenericPassword"); SecClassInternetPassword = GetSymbol(_securityHandle, "kSecClassInternetPassword"); SecAttrService = GetSymbol(_securityHandle, "kSecAttrService"); SecAttrServer = GetSymbol(_securityHandle, "kSecAttrServer"); SecAttrAccount = GetSymbol(_securityHandle, "kSecAttrAccount"); SecAttrLabel = GetSymbol(_securityHandle, "kSecAttrLabel"); SecValueData = GetSymbol(_securityHandle, "kSecValueData"); SecReturnData = GetSymbol(_securityHandle, "kSecReturnData"); SecMatchLimit = GetSymbol(_securityHandle, "kSecMatchLimit"); SecMatchLimitOne = GetSymbol(_securityHandle, "kSecMatchLimitOne"); CFBooleanTrue = GetSymbol(_coreFoundationHandle, "kCFBooleanTrue"); } /// /// Resolves a symbol and reads its pointer value. /// /// Handle to the loaded library. /// The symbol to resolve. /// Pointer to the resolved symbol. private static IntPtr GetSymbol(IntPtr handle, string name) { var symbolPtr = dlsym(handle, name); if (symbolPtr == IntPtr.Zero) throw new Exception($"Failed to resolve native symbol '{name}'"); var value = Marshal.ReadIntPtr(symbolPtr); if (value == IntPtr.Zero) throw new Exception($"Native symbol '{name}' is null"); return value; } /// /// Creates a CoreFoundation string from a managed string. /// /// The input string. /// Pointer to the CFString. internal static IntPtr CFString(string s) { var bytes = Encoding.UTF8.GetBytes(s + "\0"); var cf = CFStringCreateWithCString(IntPtr.Zero, bytes, kCFStringEncodingUTF8); if (cf == IntPtr.Zero) throw new Exception("CFStringCreateWithCString failed"); return cf; } /// /// Creates CoreFoundation data from a byte array. /// /// The byte array. /// Pointer to the CFData. internal static IntPtr CFData(byte[] bytes) { var cf = CFDataCreate(IntPtr.Zero, bytes, bytes.Length); if (cf == IntPtr.Zero) throw new Exception("CFDataCreate failed"); return cf; } /// /// Creates a new mutable CoreFoundation dictionary. /// /// Pointer to the dictionary. internal static IntPtr NewMutableDict() { var dict = CFDictionaryCreateMutable( IntPtr.Zero, 0, IntPtr.Zero, IntPtr.Zero); if (dict == IntPtr.Zero) throw new Exception("CFDictionaryCreateMutable failed"); return dict; } /// /// Sets a value in a CoreFoundation dictionary. /// /// The dictionary. /// The key. /// The value. internal static void DictSet(IntPtr dict, IntPtr key, IntPtr value) => CFDictionarySetValue(dict, key, value); } /// /// Stores a string value in the keychain asynchronously. /// /// The name/key for the secret. /// The secret value to store. /// Whether to overwrite an existing item. /// The keychain settings. /// Cancellation token. private static Task SetStringAsync(string name, string secret, bool overwrite, KeyChainSettings settings, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); SetItem(name, secret, overwrite, settings, isInternet: settings.Type == PasswordType.Internet); return Task.CompletedTask; } /// /// Returns the service name to use for the given name and settings. /// /// The name of the secret. /// The keychain settings. /// The service name to use. private static string GetServiceName(string name, KeyChainSettings settings) { if (string.IsNullOrWhiteSpace(settings.Service)) return name; return $"{settings.Service}.{name}"; } /// /// Returns the account name to use for the given name and settings. /// /// The name of the secret. /// The keychain settings. /// The account name to use. private static string GetAccountName(string name, KeyChainSettings settings) { if (string.IsNullOrWhiteSpace(settings.Account)) return name; return settings.Account; } /// /// Stores a generic password in the keychain. /// /// The label for the item. /// The secret value. /// Whether to overwrite if exists. /// The keychain settings. private static void SetItem(string label, string secret, bool overwrite, KeyChainSettings settings, bool isInternet) { var serviceOrServer = GetServiceName(label, settings); var account = GetAccountName(label, settings); IntPtr attrs = IntPtr.Zero, q = IntPtr.Zero, upd = IntPtr.Zero; IntPtr cfServiceOrServer = IntPtr.Zero, cfAccount = IntPtr.Zero, cfLabel = IntPtr.Zero, cfSecret = IntPtr.Zero; try { cfServiceOrServer = KeychainNative.CFString(serviceOrServer); cfAccount = KeychainNative.CFString(account); cfLabel = KeychainNative.CFString(label); cfSecret = KeychainNative.CFData(Encoding.UTF8.GetBytes(secret)); // Build attributes for add attrs = KeychainNative.NewMutableDict(); KeychainNative.DictSet(attrs, KeychainNative.SecClass, isInternet ? KeychainNative.SecClassInternetPassword : KeychainNative.SecClassGenericPassword); if (isInternet) KeychainNative.DictSet(attrs, KeychainNative.SecAttrServer, cfServiceOrServer); else KeychainNative.DictSet(attrs, KeychainNative.SecAttrService, cfServiceOrServer); KeychainNative.DictSet(attrs, KeychainNative.SecAttrAccount, cfAccount); KeychainNative.DictSet(attrs, KeychainNative.SecAttrLabel, cfLabel); KeychainNative.DictSet(attrs, KeychainNative.SecValueData, cfSecret); var status = KeychainNative.SecItemAdd(attrs, out var added); if (added != IntPtr.Zero) KeychainNative.CFRelease(added); if (status == KeychainNative.errSecSuccess) return; if (status != KeychainNative.errSecDuplicateItem) throw new UserInformationException( $"Failed to store secret in keychain (status {status})", "KeyChainInsertFailed"); // Duplicate item if (!overwrite) throw new UserInformationException( $"Item already exists in keychain: {label}", "KeyChainInsertFailed"); // Query for the existing item to update (exact key) q = KeychainNative.NewMutableDict(); KeychainNative.DictSet(q, KeychainNative.SecClass, isInternet ? KeychainNative.SecClassInternetPassword : KeychainNative.SecClassGenericPassword); if (isInternet) KeychainNative.DictSet(q, KeychainNative.SecAttrServer, cfServiceOrServer); else KeychainNative.DictSet(q, KeychainNative.SecAttrService, cfServiceOrServer); KeychainNative.DictSet(q, KeychainNative.SecAttrAccount, cfAccount); // Update dictionary upd = KeychainNative.NewMutableDict(); KeychainNative.DictSet(upd, KeychainNative.SecValueData, cfSecret); KeychainNative.DictSet(upd, KeychainNative.SecAttrLabel, cfLabel); status = KeychainNative.SecItemUpdate(q, upd); if (status != KeychainNative.errSecSuccess) throw new UserInformationException( $"Failed to update secret in keychain (status {status})", "KeyChainInsertFailed"); } finally { if (attrs != IntPtr.Zero) KeychainNative.CFRelease(attrs); if (q != IntPtr.Zero) KeychainNative.CFRelease(q); if (upd != IntPtr.Zero) KeychainNative.CFRelease(upd); if (cfServiceOrServer != IntPtr.Zero) KeychainNative.CFRelease(cfServiceOrServer); if (cfAccount != IntPtr.Zero) KeychainNative.CFRelease(cfAccount); if (cfLabel != IntPtr.Zero) KeychainNative.CFRelease(cfLabel); if (cfSecret != IntPtr.Zero) KeychainNative.CFRelease(cfSecret); } } /// /// Retrieves a string value from the keychain asynchronously. /// /// The name/key of the secret. /// The keychain settings. /// Cancellation token. /// The retrieved secret value. private static Task GetStringAsync(string name, KeyChainSettings settings, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); // If service/account is set (either one), try exact match first. if (!string.IsNullOrEmpty(settings.Service) || !string.IsNullOrEmpty(settings.Account)) { try { return Task.FromResult(GetByLabelCore(name, settings, settings.Type == PasswordType.Internet)); } catch (UserInformationException ex) when (ex.HelpID == "KeyChainItemMissing") { // Fallback to label-only below. } } return Task.FromResult(GetByLabelCore(name, null, settings.Type == PasswordType.Internet)); } /// /// Core method to retrieve a password by label. /// /// The label of the item. /// Whether it's an internet password. /// The password value. private static string GetByLabelCore(string name, KeyChainSettings? settings, bool isInternet) { IntPtr q = IntPtr.Zero; IntPtr cfLabel = IntPtr.Zero; IntPtr cfService = IntPtr.Zero; IntPtr cfAccount = IntPtr.Zero; IntPtr result = IntPtr.Zero; try { cfLabel = KeychainNative.CFString(name); q = KeychainNative.NewMutableDict(); KeychainNative.DictSet(q, KeychainNative.SecClass, isInternet ? KeychainNative.SecClassInternetPassword : KeychainNative.SecClassGenericPassword); // Always constrain by label/name KeychainNative.DictSet(q, KeychainNative.SecAttrLabel, cfLabel); // Optionally constrain by service + account as well if (settings != null && (!string.IsNullOrWhiteSpace(settings.Service) || !string.IsNullOrWhiteSpace(settings.Account))) { var service = GetServiceName(name, settings); var account = GetAccountName(name, settings); cfService = KeychainNative.CFString(service); cfAccount = KeychainNative.CFString(account); KeychainNative.DictSet(q, KeychainNative.SecAttrService, cfService); KeychainNative.DictSet(q, KeychainNative.SecAttrAccount, cfAccount); } KeychainNative.DictSet(q, KeychainNative.SecReturnData, KeychainNative.CFBooleanTrue); KeychainNative.DictSet(q, KeychainNative.SecMatchLimit, KeychainNative.SecMatchLimitOne); var status = KeychainNative.SecItemCopyMatching(q, out result); if (status != 0 || result == IntPtr.Zero) { var msg = settings != null ? $"Item not found in keychain: label={name}, service={GetServiceName(name, settings)}, account={GetAccountName(name, settings)}" : $"Item not found in keychain by label: {name}"; throw new UserInformationException(msg, "KeyChainItemMissing"); } var len = (int)KeychainNative.CFDataGetLength(result); if (len <= 0) throw new UserInformationException($"The key '{name}' returned an empty value", "KeyChainItemEmpty"); var ptr = KeychainNative.CFDataGetBytePtr(result); var managed = new byte[len]; Marshal.Copy(ptr, managed, 0, len); var output = Encoding.UTF8.GetString(managed).Trim(); ValidateOutput(name, output); return output; } finally { if (result != IntPtr.Zero) KeychainNative.CFRelease(result); if (q != IntPtr.Zero) KeychainNative.CFRelease(q); if (cfLabel != IntPtr.Zero) KeychainNative.CFRelease(cfLabel); if (cfService != IntPtr.Zero) KeychainNative.CFRelease(cfService); if (cfAccount != IntPtr.Zero) KeychainNative.CFRelease(cfAccount); } } /// /// Validates the retrieved output value. /// /// The name/key for error messages. /// The output string to validate. private static void ValidateOutput(string name, string output) { if (string.IsNullOrWhiteSpace(output)) throw new UserInformationException($"The key '{name}' returned an empty value", "KeyChainItemEmpty"); if (output.IndexOfAny(['\r', '\n']) >= 0) throw new UserInformationException($"The key '{name}' returned a multi-line value", "KeyChainItemMultiLine"); } }