mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-12 21:57:19 -04:00
287 lines
9.5 KiB
C#
287 lines
9.5 KiB
C#
using AudibleApi.Authorization;
|
|
using Dinah.Core.IO;
|
|
using Dinah.Core.Security;
|
|
using LibationFileManager;
|
|
using System.ComponentModel;
|
|
using System.Security.Cryptography;
|
|
|
|
namespace AudibleUtilities;
|
|
|
|
/// <summary>
|
|
/// Applies Libation's <see cref="Configuration.TokenStorageMethod"/> to AudibleApi identity persistence.
|
|
/// Changing the preference alone does not convert existing tokens.
|
|
/// </summary>
|
|
public static class IdentityTokenStorageWiring
|
|
{
|
|
public const string ApplicationName = "Libation";
|
|
|
|
/// <summary>Env var: path to a raw 32-byte master key file (from <c>export-master-key</c>).</summary>
|
|
public const string MasterKeyFileEnvVar = "LIBATION_MASTER_KEY_FILE";
|
|
|
|
/// <summary>Env var: Base64-encoded 32-byte master key.</summary>
|
|
public const string MasterKeyEnvVar = "LIBATION_MASTER_KEY";
|
|
|
|
/// <summary>Default master key file name under the Libation files directory.</summary>
|
|
public const string DefaultMasterKeyFileName = "libation-master.key";
|
|
|
|
private static Lock Gate { get; } = new();
|
|
private static Configuration? _wiredConfig;
|
|
|
|
/// <summary>
|
|
/// Configure AudibleApi token persistence from <paramref name="config"/> and keep it in sync when the preference changes.
|
|
/// </summary>
|
|
public static void Apply(Configuration config)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(config);
|
|
|
|
ConfigureFrom(config);
|
|
|
|
lock (Gate)
|
|
{
|
|
if (ReferenceEquals(_wiredConfig, config))
|
|
return;
|
|
|
|
if (_wiredConfig is not null)
|
|
_wiredConfig.PropertyChanged -= OnPropertyChanged;
|
|
|
|
_wiredConfig = config;
|
|
_wiredConfig.PropertyChanged += OnPropertyChanged;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configure AudibleApi from the current preference without attaching change listeners.
|
|
/// Prefer <see cref="Apply"/> at application startup.
|
|
/// </summary>
|
|
public static void ConfigureFrom(Configuration config)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(config);
|
|
|
|
var method = config.TokenStorageMethod;
|
|
var store = ResolveSecretStore(config);
|
|
AesGcmSecretProtector? protector = store.IsAvailable
|
|
? new AesGcmSecretProtector(store)
|
|
: null;
|
|
|
|
// Encrypted + unavailable store => protector null => fail-closed on encrypt/decrypt.
|
|
IdentityTokenStorage.Configure(method, protector);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve the secret store used for the AES-GCM master key.
|
|
/// Supported priority: <see cref="MasterKeyFileEnvVar"/> -> existing default <see cref="DefaultMasterKeyFileName"/>
|
|
/// under Libation files -> <see cref="MasterKeyEnvVar"/> -> OS-bound <see cref="OsSecretStore"/>.
|
|
/// If the OS store is unavailable, falls through to
|
|
/// <see cref="TryCreateLastResortPortableMasterKeyStore"/> (headless compatibility path; not the preferred setup).
|
|
/// </summary>
|
|
public static IOsSecretStore ResolveSecretStore(Configuration config)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(config);
|
|
|
|
var keyFileEnv = Environment.GetEnvironmentVariable(MasterKeyFileEnvVar);
|
|
if (!string.IsNullOrWhiteSpace(keyFileEnv))
|
|
return LoadMasterKeyFileOrUnavailable(keyFileEnv.Trim());
|
|
|
|
var defaultKeyPath = Path.Combine(config.LibationFiles.Location, DefaultMasterKeyFileName);
|
|
if (File.Exists(defaultKeyPath))
|
|
return LoadMasterKeyFileOrUnavailable(defaultKeyPath);
|
|
|
|
var keyEnv = Environment.GetEnvironmentVariable(MasterKeyEnvVar);
|
|
if (!string.IsNullOrWhiteSpace(keyEnv))
|
|
return LoadMasterKeyBase64OrUnavailable(keyEnv.Trim());
|
|
|
|
var osStore = OsSecretStore.Create(ApplicationName);
|
|
if (osStore.IsAvailable)
|
|
return osStore;
|
|
|
|
return TryCreateLastResortPortableMasterKeyStore(config);
|
|
}
|
|
|
|
/// <summary>True when the OS secret store can hold Libation's encryption master key.</summary>
|
|
public static bool IsOsSecretStoreAvailable(out string? unavailableReason)
|
|
{
|
|
var store = OsSecretStore.Create(ApplicationName);
|
|
unavailableReason = store.IsAvailable ? null : store.UnavailableReason;
|
|
return store.IsAvailable;
|
|
}
|
|
|
|
/// <summary>True when any resolved secret store (portable or OS) can supply an encryption master key.</summary>
|
|
public static bool IsEncryptionKeyAvailable(Configuration config, out string? unavailableReason)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(config);
|
|
var store = ResolveSecretStore(config);
|
|
unavailableReason = store.IsAvailable ? null : store.UnavailableReason;
|
|
return store.IsAvailable;
|
|
}
|
|
|
|
/// <summary>Sidecar notice written beside an auto-minted last-resort master key.</summary>
|
|
public const string LastResortMasterKeyNoticeFileName = "libation-master.key.NOTICE.txt";
|
|
|
|
/// <summary>
|
|
/// Headless / Docker compatibility path when encryption is enabled but there is no OS secret store
|
|
/// and the user did not supply a master key.
|
|
/// Prefer setting <see cref="Configuration.TokenStorageMethod"/> to plaintext, or supplying an exported
|
|
/// <see cref="DefaultMasterKeyFileName"/> / env key, instead of relying on this path.
|
|
/// Creates <see cref="DefaultMasterKeyFileName"/> under Libation files if missing, then loads it.
|
|
/// </summary>
|
|
internal static IOsSecretStore TryCreateLastResortPortableMasterKeyStore(Configuration config)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(config);
|
|
|
|
var keyPath = Path.Combine(config.LibationFiles.Location, DefaultMasterKeyFileName);
|
|
try
|
|
{
|
|
if (!File.Exists(keyPath))
|
|
{
|
|
MintRawMasterKeyFile(keyPath);
|
|
AnnounceLastResortPortableMasterKey(keyPath);
|
|
}
|
|
|
|
return LoadMasterKeyFileOrUnavailable(keyPath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new UnavailablePortableSecretStore(
|
|
"Last-resort portable master key",
|
|
$"Could not create or load last-resort portable master key ({keyPath}): {SafeMessage(ex)}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Warn that a last-resort portable master key was auto-created. Not the recommended setup.
|
|
/// </summary>
|
|
internal static void AnnounceLastResortPortableMasterKey(string keyPath)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(keyPath);
|
|
|
|
var fullKeyPath = Path.GetFullPath(keyPath);
|
|
var noticePath = Path.Combine(
|
|
Path.GetDirectoryName(fullKeyPath) ?? ".",
|
|
LastResortMasterKeyNoticeFileName);
|
|
|
|
var message =
|
|
"LAST-RESORT: Token encryption is enabled, but no OS secret store is available and no master key was supplied. "
|
|
+ $"Libation created a portable master key at '{fullKeyPath}'. "
|
|
+ "This is a compatibility fallback, not the recommended setup. "
|
|
+ "Prefer: set TokenStorageMethod to Plaintext in Settings, "
|
|
+ $"OR supply your own key (export from desktop and place '{DefaultMasterKeyFileName}' next to AccountsSettings.json, "
|
|
+ $"or set {MasterKeyFileEnvVar} / {MasterKeyEnvVar}). "
|
|
+ "Treat the key file like a password. It will not decrypt tokens encrypted under a different machine's key.";
|
|
|
|
try
|
|
{
|
|
AtomicFileWriter.WriteAllText(noticePath, message + Environment.NewLine);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Serilog.Log.Logger.Warning(ex, "Could not write last-resort master key notice file at {NoticePath}", noticePath);
|
|
}
|
|
|
|
Serilog.Log.Logger.Warning(message);
|
|
Console.Error.WriteLine(message);
|
|
}
|
|
|
|
/// <summary>Write a new raw 32-byte master key file (same format as <c>export-master-key</c>).</summary>
|
|
private static void MintRawMasterKeyFile(string keyPath)
|
|
{
|
|
var key = new byte[AesGcmSecretProtector.KeySizeBytes];
|
|
RandomNumberGenerator.Fill(key);
|
|
try
|
|
{
|
|
var directory = Path.GetDirectoryName(Path.GetFullPath(keyPath));
|
|
if (!string.IsNullOrEmpty(directory))
|
|
Directory.CreateDirectory(directory);
|
|
|
|
AtomicFileWriter.WriteAllBytes(keyPath, key);
|
|
}
|
|
finally
|
|
{
|
|
CryptographicOperations.ZeroMemory(key);
|
|
}
|
|
}
|
|
|
|
private static IOsSecretStore LoadMasterKeyFileOrUnavailable(string path)
|
|
{
|
|
var store = new MemoryOsSecretStore();
|
|
try
|
|
{
|
|
MasterKeyPortability.ImportFromFile(store, path);
|
|
return store;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new UnavailablePortableSecretStore(
|
|
"Portable master key file",
|
|
$"Portable master key file is unusable ({path}): {SafeMessage(ex)}");
|
|
}
|
|
}
|
|
|
|
private static IOsSecretStore LoadMasterKeyBase64OrUnavailable(string base64)
|
|
{
|
|
byte[] key;
|
|
try
|
|
{
|
|
key = Convert.FromBase64String(base64);
|
|
}
|
|
catch (FormatException ex)
|
|
{
|
|
return new UnavailablePortableSecretStore(
|
|
"Portable master key env",
|
|
$"{MasterKeyEnvVar} is not valid Base64: {SafeMessage(ex)}");
|
|
}
|
|
|
|
try
|
|
{
|
|
if (key.Length != AesGcmSecretProtector.KeySizeBytes)
|
|
{
|
|
return new UnavailablePortableSecretStore(
|
|
"Portable master key env",
|
|
$"{MasterKeyEnvVar} must decode to {AesGcmSecretProtector.KeySizeBytes} bytes.");
|
|
}
|
|
|
|
var store = new MemoryOsSecretStore();
|
|
store.Set(AesGcmSecretProtector.DefaultMasterKeyName, key);
|
|
return store;
|
|
}
|
|
finally
|
|
{
|
|
CryptographicOperations.ZeroMemory(key);
|
|
}
|
|
}
|
|
|
|
private static string SafeMessage(Exception ex)
|
|
=> string.IsNullOrWhiteSpace(ex.Message) ? ex.GetType().Name : ex.Message;
|
|
|
|
private static void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
|
{
|
|
if (e.PropertyName != nameof(Configuration.TokenStorageMethod))
|
|
return;
|
|
if (sender is not Configuration config)
|
|
return;
|
|
|
|
ConfigureFrom(config);
|
|
}
|
|
|
|
private sealed class UnavailablePortableSecretStore : IOsSecretStore
|
|
{
|
|
public UnavailablePortableSecretStore(string name, string reason)
|
|
{
|
|
Name = name;
|
|
UnavailableReason = reason;
|
|
}
|
|
|
|
public string Name { get; }
|
|
public bool IsAvailable => false;
|
|
public string? UnavailableReason { get; }
|
|
|
|
public void Set(string key, ReadOnlySpan<byte> value)
|
|
=> throw new OsSecretStoreUnavailableException(Name, UnavailableReason!);
|
|
|
|
public bool TryGet(string key, out byte[] value)
|
|
=> throw new OsSecretStoreUnavailableException(Name, UnavailableReason!);
|
|
|
|
public void Delete(string key)
|
|
=> throw new OsSecretStoreUnavailableException(Name, UnavailableReason!);
|
|
}
|
|
}
|