831 lines
35 KiB
C#
831 lines
35 KiB
C#
// Copyright (C) 2026, 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;
|
|
|
|
/// <summary>
|
|
/// Implementation of a secret provider that reads secrets from the MacOS keychain
|
|
/// </summary>
|
|
[SupportedOSPlatform("macos")]
|
|
public class MacOSKeyChainProvider : ISecretProvider
|
|
{
|
|
/// <inheritdoc />
|
|
public string Key => "keychain";
|
|
|
|
/// <inheritdoc />
|
|
public string DisplayName => Strings.MacOSKeyChainProvider.DisplayName;
|
|
|
|
/// <inheritdoc />
|
|
public string Description => Strings.MacOSKeyChainProvider.Description;
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> IsSupported(CancellationToken cancellationToken) => Task.FromResult(OperatingSystem.IsMacOS());
|
|
|
|
/// <inheritdoc />
|
|
public bool IsSetSupported => true;
|
|
|
|
/// <summary>
|
|
/// The type of password to get
|
|
/// </summary>
|
|
private enum PasswordType
|
|
{
|
|
/// <summary>
|
|
/// A generic password
|
|
/// </summary>
|
|
Generic,
|
|
/// <summary>
|
|
/// An internet password
|
|
/// </summary>
|
|
Internet
|
|
}
|
|
|
|
/// <summary>
|
|
/// The settings for the keychain
|
|
/// </summary>
|
|
private class KeyChainSettings : ICommandLineArgumentMapper
|
|
{
|
|
/// <summary>
|
|
/// The default service name
|
|
/// </summary>
|
|
private const string ServiceDefault = "com.duplicati.secrets";
|
|
|
|
/// <summary>
|
|
/// The default account name
|
|
/// </summary>
|
|
private const string AccountDefault = "Duplicati";
|
|
|
|
/// <summary>
|
|
/// The service name
|
|
/// </summary>
|
|
public string? Service { get; set; } = ServiceDefault;
|
|
/// <summary>
|
|
/// The account name
|
|
/// </summary>
|
|
public string? Account { get; set; } = AccountDefault;
|
|
|
|
/// <summary>
|
|
/// Gets or sets a value indicating whether to use internet passwords as opposed to generic passwords
|
|
/// </summary>
|
|
public PasswordType Type { get; set; } = PasswordType.Generic;
|
|
|
|
/// <summary>
|
|
/// Gets the command line argument description for a member
|
|
/// </summary>
|
|
/// <param name="name">The name of the member</param>
|
|
/// <returns>The command line argument description</returns>
|
|
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
|
|
};
|
|
|
|
/// <inheritdoc />
|
|
CommandLineArgumentDescriptionAttribute? ICommandLineArgumentMapper.GetCommandLineArgumentDescription(MemberInfo mi)
|
|
=> GetCommandLineArgumentDescription(mi.Name);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The settings for the keychain; null if not initialized
|
|
/// </summary>
|
|
private KeyChainSettings? _settings;
|
|
|
|
/// <inheritdoc />
|
|
public IList<ICommandLineArgument> SupportedCommands
|
|
=> CommandLineArgumentMapper.MapArguments(new KeyChainSettings()).ToList();
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Dictionary<string, string>> ResolveSecretsAsync(IEnumerable<string> keys, CancellationToken cancellationToken)
|
|
{
|
|
if (_settings is null)
|
|
throw new InvalidOperationException("The MacOSKeyChainProvider has not been initialized");
|
|
|
|
var result = new Dictionary<string, string>();
|
|
foreach (var key in keys)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var value = await GetStringAsync(key, _settings, cancellationToken).ConfigureAwait(false);
|
|
result[key] = value;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Native methods for accessing the MacOS keychain
|
|
/// </summary>
|
|
private static class KeychainNative
|
|
{
|
|
/// <summary>
|
|
/// The path to the Security framework
|
|
/// </summary>
|
|
private const string SecurityLib = "/System/Library/Frameworks/Security.framework/Security";
|
|
/// <summary>
|
|
/// The path to the CoreFoundation framework
|
|
/// </summary>
|
|
private const string CoreFoundationLib = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
|
|
|
|
/// <summary>
|
|
/// Success status code
|
|
/// </summary>
|
|
public const int errSecSuccess = 0;
|
|
/// <summary>
|
|
/// Duplicate item status code
|
|
/// </summary>
|
|
public const int errSecDuplicateItem = -25299;
|
|
|
|
// Classic exact-match APIs (fast path)
|
|
/// <summary>
|
|
/// Finds a generic password item in the keychain.
|
|
/// </summary>
|
|
/// <param name="keychain">The keychain to search, or null for default.</param>
|
|
/// <param name="serviceNameLength">Length of the service name.</param>
|
|
/// <param name="serviceName">The service name.</param>
|
|
/// <param name="accountNameLength">Length of the account name.</param>
|
|
/// <param name="accountName">The account name.</param>
|
|
/// <param name="passwordLength">Output: length of the password data.</param>
|
|
/// <param name="passwordData">Output: pointer to the password data.</param>
|
|
/// <param name="itemRef">Output: reference to the keychain item.</param>
|
|
/// <returns>Status code; 0 for success.</returns>
|
|
[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);
|
|
|
|
/// <summary>
|
|
/// Finds an internet password item in the keychain.
|
|
/// </summary>
|
|
/// <param name="keychain">The keychain to search, or null for default.</param>
|
|
/// <param name="serverNameLength">Length of the server name.</param>
|
|
/// <param name="serverName">The server name.</param>
|
|
/// <param name="securityDomainLength">Length of the security domain.</param>
|
|
/// <param name="securityDomain">The security domain.</param>
|
|
/// <param name="accountNameLength">Length of the account name.</param>
|
|
/// <param name="accountName">The account name.</param>
|
|
/// <param name="pathLength">Length of the path.</param>
|
|
/// <param name="path">The path.</param>
|
|
/// <param name="port">The port number.</param>
|
|
/// <param name="protocol">The protocol type.</param>
|
|
/// <param name="authType">The authentication type.</param>
|
|
/// <param name="passwordLength">Output: length of the password data.</param>
|
|
/// <param name="passwordData">Output: pointer to the password data.</param>
|
|
/// <param name="itemRef">Output: reference to the keychain item.</param>
|
|
/// <returns>Status code; 0 for success.</returns>
|
|
[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);
|
|
|
|
/// <summary>
|
|
/// Modifies the attributes and data of a keychain item.
|
|
/// </summary>
|
|
/// <param name="itemRef">Reference to the keychain item.</param>
|
|
/// <param name="attrList">List of attributes to modify, or null.</param>
|
|
/// <param name="length">Length of the data.</param>
|
|
/// <param name="data">The new data.</param>
|
|
/// <returns>Status code; 0 for success.</returns>
|
|
[DllImport(SecurityLib)]
|
|
internal static extern int SecKeychainItemModifyAttributesAndData(
|
|
IntPtr itemRef,
|
|
IntPtr attrList,
|
|
uint length,
|
|
byte[] data);
|
|
|
|
/// <summary>
|
|
/// Frees the memory allocated for keychain item content.
|
|
/// </summary>
|
|
/// <param name="attrList">The attribute list to free, or null.</param>
|
|
/// <param name="data">The data to free.</param>
|
|
/// <returns>Status code; 0 for success.</returns>
|
|
[DllImport(SecurityLib)]
|
|
internal static extern int SecKeychainItemFreeContent(IntPtr attrList, IntPtr data);
|
|
|
|
// Modern SecItem* APIs (label-aware add/update + label-only lookup)
|
|
/// <summary>
|
|
/// Copies matching items from the keychain.
|
|
/// </summary>
|
|
/// <param name="query">Dictionary containing the query parameters.</param>
|
|
/// <param name="result">Output: the matching item or items.</param>
|
|
/// <returns>Status code; 0 for success.</returns>
|
|
[DllImport(SecurityLib)]
|
|
internal static extern int SecItemCopyMatching(IntPtr query, out IntPtr result);
|
|
|
|
/// <summary>
|
|
/// Adds an item to the keychain.
|
|
/// </summary>
|
|
/// <param name="attributes">Dictionary containing the item attributes.</param>
|
|
/// <param name="result">Output: reference to the added item.</param>
|
|
/// <returns>Status code; 0 for success.</returns>
|
|
[DllImport(SecurityLib)]
|
|
internal static extern int SecItemAdd(IntPtr attributes, out IntPtr result);
|
|
|
|
/// <summary>
|
|
/// Updates an item in the keychain.
|
|
/// </summary>
|
|
/// <param name="query">Dictionary identifying the item to update.</param>
|
|
/// <param name="attributesToUpdate">Dictionary containing the attributes to update.</param>
|
|
/// <returns>Status code; 0 for success.</returns>
|
|
[DllImport(SecurityLib)]
|
|
internal static extern int SecItemUpdate(IntPtr query, IntPtr attributesToUpdate);
|
|
|
|
// CF helpers
|
|
/// <summary>
|
|
/// Creates a mutable CoreFoundation dictionary.
|
|
/// </summary>
|
|
/// <param name="allocator">The allocator to use, or null for default.</param>
|
|
/// <param name="capacity">Initial capacity of the dictionary.</param>
|
|
/// <param name="keyCallBacks">Callbacks for key operations.</param>
|
|
/// <param name="valueCallBacks">Callbacks for value operations.</param>
|
|
/// <returns>Pointer to the created dictionary.</returns>
|
|
[DllImport(CoreFoundationLib)]
|
|
internal static extern IntPtr CFDictionaryCreateMutable(
|
|
IntPtr allocator,
|
|
nint capacity,
|
|
IntPtr keyCallBacks,
|
|
IntPtr valueCallBacks);
|
|
|
|
/// <summary>
|
|
/// Sets a value in a CoreFoundation dictionary.
|
|
/// </summary>
|
|
/// <param name="dict">The dictionary.</param>
|
|
/// <param name="key">The key.</param>
|
|
/// <param name="value">The value.</param>
|
|
[DllImport(CoreFoundationLib)]
|
|
internal static extern void CFDictionarySetValue(IntPtr dict, IntPtr key, IntPtr value);
|
|
|
|
/// <summary>
|
|
/// Creates a CoreFoundation string from a C string.
|
|
/// </summary>
|
|
/// <param name="alloc">The allocator to use, or null for default.</param>
|
|
/// <param name="cStr">The C string bytes.</param>
|
|
/// <param name="encoding">The string encoding.</param>
|
|
/// <returns>Pointer to the created string.</returns>
|
|
[DllImport(CoreFoundationLib)]
|
|
internal static extern IntPtr CFStringCreateWithCString(
|
|
IntPtr alloc,
|
|
byte[] cStr,
|
|
uint encoding);
|
|
|
|
/// <summary>
|
|
/// Creates CoreFoundation data from a byte array.
|
|
/// </summary>
|
|
/// <param name="allocator">The allocator to use, or null for default.</param>
|
|
/// <param name="bytes">The byte array.</param>
|
|
/// <param name="length">The length of the data.</param>
|
|
/// <returns>Pointer to the created data.</returns>
|
|
[DllImport(CoreFoundationLib)]
|
|
internal static extern IntPtr CFDataCreate(
|
|
IntPtr allocator,
|
|
byte[] bytes,
|
|
nint length);
|
|
|
|
/// <summary>
|
|
/// Gets the length of CoreFoundation data.
|
|
/// </summary>
|
|
/// <param name="data">The data object.</param>
|
|
/// <returns>The length of the data.</returns>
|
|
[DllImport(CoreFoundationLib)]
|
|
internal static extern nint CFDataGetLength(IntPtr data);
|
|
|
|
/// <summary>
|
|
/// Gets a pointer to the bytes of CoreFoundation data.
|
|
/// </summary>
|
|
/// <param name="data">The data object.</param>
|
|
/// <returns>Pointer to the byte data.</returns>
|
|
[DllImport(CoreFoundationLib)]
|
|
internal static extern IntPtr CFDataGetBytePtr(IntPtr data);
|
|
|
|
/// <summary>
|
|
/// Releases a CoreFoundation object.
|
|
/// </summary>
|
|
/// <param name="cf">The object to release.</param>
|
|
[DllImport(CoreFoundationLib)]
|
|
internal static extern void CFRelease(IntPtr cf);
|
|
|
|
/// <summary>
|
|
/// UTF-8 encoding constant for CoreFoundation strings.
|
|
/// </summary>
|
|
internal const uint kCFStringEncodingUTF8 = 0x08000100;
|
|
|
|
// Constant pointers exported by Security/CoreFoundation, loaded via dlsym
|
|
/// <summary>
|
|
/// Path to libSystem (for dlopen/dlsym).
|
|
/// </summary>
|
|
private const string LibSystem = "/usr/lib/libSystem.B.dylib";
|
|
|
|
/// <summary>
|
|
/// Loads a dynamic library.
|
|
/// </summary>
|
|
/// <param name="path">The path to the library.</param>
|
|
/// <param name="mode">The loading mode.</param>
|
|
/// <returns>Handle to the loaded library.</returns>
|
|
[DllImport(LibSystem, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
|
|
private static extern IntPtr dlopen(string path, int mode);
|
|
|
|
/// <summary>
|
|
/// Resolves a symbol from a dynamic library.
|
|
/// </summary>
|
|
/// <param name="handle">Handle to the loaded library.</param>
|
|
/// <param name="symbol">The symbol to resolve.</param>
|
|
/// <returns>Pointer to the resolved symbol.</returns>
|
|
[DllImport(LibSystem, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
|
|
private static extern IntPtr dlsym(IntPtr handle, string symbol);
|
|
|
|
/// <summary>
|
|
/// Closes a dynamic library.
|
|
/// </summary>
|
|
/// <param name="handle">Handle to the loaded library.</param>
|
|
/// <returns>Zero on success.</returns>
|
|
[DllImport(LibSystem, CallingConvention = CallingConvention.Cdecl)]
|
|
private static extern int dlclose(IntPtr handle);
|
|
|
|
private static readonly IntPtr _securityHandle;
|
|
private static readonly IntPtr _coreFoundationHandle;
|
|
|
|
/// <summary>
|
|
/// Key for the class attribute in keychain queries.
|
|
/// </summary>
|
|
internal static readonly IntPtr SecClass;
|
|
/// <summary>
|
|
/// Value for generic password class.
|
|
/// </summary>
|
|
internal static readonly IntPtr SecClassGenericPassword;
|
|
/// <summary>
|
|
/// Value for internet password class.
|
|
/// </summary>
|
|
internal static readonly IntPtr SecClassInternetPassword;
|
|
/// <summary>
|
|
/// Key for the service attribute.
|
|
/// </summary>
|
|
internal static readonly IntPtr SecAttrService;
|
|
/// <summary>
|
|
/// Key for the server attribute.
|
|
/// </summary>
|
|
internal static readonly IntPtr SecAttrServer;
|
|
/// <summary>
|
|
/// Key for the account attribute.
|
|
/// </summary>
|
|
internal static readonly IntPtr SecAttrAccount;
|
|
/// <summary>
|
|
/// Key for the label attribute.
|
|
/// </summary>
|
|
internal static readonly IntPtr SecAttrLabel;
|
|
/// <summary>
|
|
/// Key for the value data.
|
|
/// </summary>
|
|
internal static readonly IntPtr SecValueData;
|
|
/// <summary>
|
|
/// Key to specify returning data in queries.
|
|
/// </summary>
|
|
internal static readonly IntPtr SecReturnData;
|
|
/// <summary>
|
|
/// Key for match limit in queries.
|
|
/// </summary>
|
|
internal static readonly IntPtr SecMatchLimit;
|
|
/// <summary>
|
|
/// Value for limiting matches to one.
|
|
/// </summary>
|
|
internal static readonly IntPtr SecMatchLimitOne;
|
|
/// <summary>
|
|
/// CoreFoundation true boolean value.
|
|
/// </summary>
|
|
internal static readonly IntPtr CFBooleanTrue;
|
|
|
|
/// <summary>
|
|
/// Static constructor to load native libraries and resolve constant symbols.
|
|
/// </summary>
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves a symbol and reads its pointer value.
|
|
/// </summary>
|
|
/// <param name="handle">Handle to the loaded library.</param>
|
|
/// <param name="name">The symbol to resolve.</param>
|
|
/// <returns>Pointer to the resolved symbol.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a CoreFoundation string from a managed string.
|
|
/// </summary>
|
|
/// <param name="s">The input string.</param>
|
|
/// <returns>Pointer to the CFString.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates CoreFoundation data from a byte array.
|
|
/// </summary>
|
|
/// <param name="bytes">The byte array.</param>
|
|
/// <returns>Pointer to the CFData.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new mutable CoreFoundation dictionary.
|
|
/// </summary>
|
|
/// <returns>Pointer to the dictionary.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets a value in a CoreFoundation dictionary.
|
|
/// </summary>
|
|
/// <param name="dict">The dictionary.</param>
|
|
/// <param name="key">The key.</param>
|
|
/// <param name="value">The value.</param>
|
|
internal static void DictSet(IntPtr dict, IntPtr key, IntPtr value)
|
|
=> CFDictionarySetValue(dict, key, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stores a string value in the keychain asynchronously.
|
|
/// </summary>
|
|
/// <param name="name">The name/key for the secret.</param>
|
|
/// <param name="secret">The secret value to store.</param>
|
|
/// <param name="overwrite">Whether to overwrite an existing item.</param>
|
|
/// <param name="settings">The keychain settings.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the service name to use for the given name and settings.
|
|
/// </summary>
|
|
/// <param name="name">The name of the secret.</param>
|
|
/// <param name="settings">The keychain settings.</param>
|
|
/// <returns>The service name to use.</returns>
|
|
private static string GetServiceName(string name, KeyChainSettings settings)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(settings.Service))
|
|
return name;
|
|
return $"{settings.Service}.{name}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the account name to use for the given name and settings.
|
|
/// </summary>
|
|
/// <param name="name">The name of the secret.</param>
|
|
/// <param name="settings">The keychain settings.</param>
|
|
/// <returns>The account name to use.</returns>
|
|
private static string GetAccountName(string name, KeyChainSettings settings)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(settings.Account))
|
|
return name;
|
|
return settings.Account;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stores a generic password in the keychain.
|
|
/// </summary>
|
|
/// <param name="label">The label for the item.</param>
|
|
/// <param name="secret">The secret value.</param>
|
|
/// <param name="overwrite">Whether to overwrite if exists.</param>
|
|
/// <param name="settings">The keychain settings.</param>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves a string value from the keychain asynchronously.
|
|
/// </summary>
|
|
/// <param name="name">The name/key of the secret.</param>
|
|
/// <param name="settings">The keychain settings.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The retrieved secret value.</returns>
|
|
private static Task<string> 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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Core method to retrieve a password by label.
|
|
/// </summary>
|
|
/// <param name="label">The label of the item.</param>
|
|
/// <param name="isInternet">Whether it's an internet password.</param>
|
|
/// <returns>The password value.</returns>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates the retrieved output value.
|
|
/// </summary>
|
|
/// <param name="name">The name/key for error messages.</param>
|
|
/// <param name="output">The output string to validate.</param>
|
|
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");
|
|
}
|
|
} |