diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs
index 5f181eb3..c6b3a38f 100644
--- a/Source/AppScaffolding/LibationScaffolding.cs
+++ b/Source/AppScaffolding/LibationScaffolding.cs
@@ -96,6 +96,7 @@ public static class LibationScaffolding
}
DeleteOpenSqliteFiles(config);
AudibleApiStorage.EnsureAccountsSettingsFileExists();
+ IdentityTokenStorageWiring.Apply(config);
//
// migrations go below here
diff --git a/Source/AudibleUtilities/AccountTokenStorage.cs b/Source/AudibleUtilities/AccountTokenStorage.cs
new file mode 100644
index 00000000..8258026b
--- /dev/null
+++ b/Source/AudibleUtilities/AccountTokenStorage.cs
@@ -0,0 +1,59 @@
+using AudibleApi.Authorization;
+
+namespace AudibleUtilities;
+
+///
+/// Alignment and conversion helpers for identity tokens in AccountsSettings.json.
+/// Preference changes alone do not convert; conversion is explicit via .
+///
+public static class AccountTokenStorage
+{
+ ///
+ /// Compare persisted encryption metadata for all account identities to .
+ ///
+ public static TokenStorageAlignment GetAccountsAlignment(TokenStorageMethod method, string? accountsSettingsFile = null)
+ {
+ var path = accountsSettingsFile ?? AudibleApiStorage.AccountsSettingsFile;
+ if (!File.Exists(path))
+ return TokenStorageAlignment.NoApplicableTokens;
+
+ using var persister = new AccountsSettingsPersister(path);
+ var alignments = new List();
+
+ foreach (var account in persister.AccountsSettings.Accounts)
+ {
+ if (account.IdentityTokens is null)
+ continue;
+
+ alignments.Add(IdentityTokenConversion.GetAlignment(account.IdentityTokens, method));
+ }
+
+ return CombineAlignments(alignments);
+ }
+
+ ///
+ /// Convert every identity in the accounts settings file to .
+ /// All-or-nothing: failure leaves the original file unchanged.
+ ///
+ public static IdentityTokenConversionResult ConvertAllAccounts(TokenStorageMethod method, string? accountsSettingsFile = null)
+ {
+ var path = accountsSettingsFile ?? AudibleApiStorage.AccountsSettingsFile;
+ return IdentityTokenConversion.ConvertAllIdentitiesInFile(path, method);
+ }
+
+ private static TokenStorageAlignment CombineAlignments(IReadOnlyList alignments)
+ {
+ if (alignments.Count == 0)
+ return TokenStorageAlignment.NoApplicableTokens;
+ if (alignments.Any(a => a == TokenStorageAlignment.Indeterminate))
+ return TokenStorageAlignment.Indeterminate;
+ if (alignments.All(a => a is TokenStorageAlignment.AllMatch or TokenStorageAlignment.NoApplicableTokens))
+ {
+ return alignments.Any(a => a == TokenStorageAlignment.AllMatch)
+ ? TokenStorageAlignment.AllMatch
+ : TokenStorageAlignment.NoApplicableTokens;
+ }
+
+ return TokenStorageAlignment.SomeMismatch;
+ }
+}
diff --git a/Source/AudibleUtilities/IdentityTokenStorageWiring.cs b/Source/AudibleUtilities/IdentityTokenStorageWiring.cs
new file mode 100644
index 00000000..45db7fb8
--- /dev/null
+++ b/Source/AudibleUtilities/IdentityTokenStorageWiring.cs
@@ -0,0 +1,76 @@
+using AudibleApi.Authorization;
+using Dinah.Core.Security;
+using LibationFileManager;
+using System.ComponentModel;
+
+namespace AudibleUtilities;
+
+///
+/// Applies Libation's to AudibleApi identity persistence.
+/// Changing the preference alone does not convert existing tokens.
+///
+public static class IdentityTokenStorageWiring
+{
+ public const string ApplicationName = "Libation";
+
+ private static Lock Gate { get; } = new();
+ private static Configuration? _wiredConfig;
+
+ ///
+ /// Configure AudibleApi token persistence from and keep it in sync when the preference changes.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// Configure AudibleApi from the current preference without attaching change listeners.
+ /// Prefer at application startup.
+ ///
+ public static void ConfigureFrom(Configuration config)
+ {
+ ArgumentNullException.ThrowIfNull(config);
+
+ var method = config.TokenStorageMethod;
+ var store = OsSecretStore.Create(ApplicationName);
+ AesGcmSecretProtector? protector = store.IsAvailable
+ ? new AesGcmSecretProtector(store)
+ : null;
+
+ // Encrypted + unavailable store => protector null => fail-closed on encrypt/decrypt.
+ IdentityTokenStorage.Configure(method, protector);
+ }
+
+ /// True when the OS secret store can hold Libation's encryption master key.
+ public static bool IsOsSecretStoreAvailable(out string? unavailableReason)
+ {
+ var store = OsSecretStore.Create(ApplicationName);
+ unavailableReason = store.IsAvailable ? null : store.UnavailableReason;
+ return store.IsAvailable;
+ }
+
+ private static void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName != nameof(Configuration.TokenStorageMethod))
+ return;
+ if (sender is not Configuration config)
+ return;
+
+ ConfigureFrom(config);
+ }
+}
diff --git a/Source/LibationFileManager/Configuration.PersistentSettings.cs b/Source/LibationFileManager/Configuration.PersistentSettings.cs
index 293e204d..1e995260 100644
--- a/Source/LibationFileManager/Configuration.PersistentSettings.cs
+++ b/Source/LibationFileManager/Configuration.PersistentSettings.cs
@@ -1,3 +1,4 @@
+using AudibleApi.Authorization;
using FileManager;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
@@ -301,6 +302,13 @@ public partial class Configuration
AC_4
}
+ [Description("Store authentication tokens encrypted using the OS secret store.\r\nUnchecking stores tokens in plaintext in AccountsSettings.json.\r\nChanging this setting does not convert existing tokens until you choose to convert them.")]
+ public TokenStorageMethod TokenStorageMethod
+ {
+ get => GetNonString(defaultValue: TokenStorageMethod.Encrypted);
+ set => SetNonString(value);
+ }
+
[Description("Use Widevine DRM")]
public bool UseWidevine { get => GetNonString(defaultValue: false); set => SetNonString(value); }
diff --git a/Source/_Tests/AudibleUtilities.Tests/IdentityTokenStorageWiringTests.cs b/Source/_Tests/AudibleUtilities.Tests/IdentityTokenStorageWiringTests.cs
new file mode 100644
index 00000000..87bd2d18
--- /dev/null
+++ b/Source/_Tests/AudibleUtilities.Tests/IdentityTokenStorageWiringTests.cs
@@ -0,0 +1,204 @@
+using AssertionHelper;
+using AudibleApi;
+using AudibleApi.Authorization;
+using AudibleApi.Cryptography;
+using AudibleUtilities;
+using Dinah.Core.Security;
+using LibationFileManager;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+namespace IdentityTokenStorageWiringTests;
+
+[TestClass]
+[DoNotParallelize]
+public class IdentityTokenStorageWiringTests
+{
+ // Fixture material only (same shape as AudibleApi characterization tests).
+ private const string SampleAccessToken = "Atna|_CHAR_ACCESS_";
+ private const string SampleRefreshToken = "Atnr|_CHAR_REFRESH_";
+ private const string SampleAdpToken = "{enc:abcdefg}{key:1234}{iv:56789}{name:QURQVG9rZW5FbmNyeXB0aW9uS2V5}{serial:Mg==}";
+ private const string SampleStoreAuthCookie = "store-auth-cookie-value";
+ private const string SampleCookieName = "session-id";
+ private const string SampleCookieValue = "cookie-secret-value";
+ private static readonly DateTime SampleExpires = new(2200, 1, 1, 12, 0, 0, DateTimeKind.Utc);
+ private const string SamplePrivateKey = @"
+-----BEGIN RSA PRIVATE KEY-----
+MIIEpgIBAAKCAQEA5nPbGSVDmlEH2tJa6kz/P2HI8IeirhfPHdmi+X/nsb9i3WNf
+tmEdZxfK26IValQDXvBH17a1gr0HD6pYse1XsV2w0HxiW1RW+ZnjL8/fzPdkSOb+
+4xKlqRopCueBSdDGgAF06spZ3IeHLfEFOJX4dO1Y73pFBUkA0k53LT12L2Tjay/r
+buZHJqIzxmwja7/nkiWL0Xo7UySHtQACYsKEatu6yHBS+cPTlGR/qeUpeJTHwDLP
+7ZQ7kWzJGY1mfInYekjlZLsMsWswso3pg1vPyHgxzM2BWhY8m6mlXQ9G/USxBTib
+MNuMtpR73XsgamneFCc+Uv1cxw7ofZ41YOOAbQIDAQABAoIBAQDIre8HkKm0Aggj
+B7df/TjxCsgenR6PF/Cmf9UqC7XJ1W3UeCrq+NrP4aonZJfdhdeBnyAQuuyJMu6p
+N6ARISuSKpJEm2xTN7idluJ9yjmLlYtg6LbhKmXUQhGniz3M999DrQERTLDAF80h
+tpbjVcWMnPsrX4AnQBFVEjs5zCHU1hD+X463EmUHBWyT975jbZ8Fy7/fTzkdzLnn
+qE5lROALr2MCAAwQRFbRE6dd52vnXaBrVcAtRzjATts3WG3+SNi2Fm/OrYqQcY9e
+lBexNviT8VcldOAMrO10E2u0d+tvxFzwB3ABMvaVamrEZky4XSfB6aLzpD0JJj1s
+UHnIiVwJAoGBAPl8nLll/J9rud/N2HiAX2YkP0MC0HW4yM3KxLtXKyXrP5qBpaci
+wTDUmSWEEE3GUJMM1Z4d9tl9Lz2MhU2KqkEvLI3kQ7aUu33PYUBGMVcUzhFQ49lU
+Nzz8YB183iqo31o/DKk2Cr5gI7SykQZ0gn/urZkEJeErLzlhPXcyeY5jAoGBAOx4
+CGucVdv5MbdXZP8jVzxuvUlSp7BIQJ2phQXDFBNApFKnZn7yBYBx7dqzleymGm+R
+INZAurg3SNw4nvbQc3Z2dJ8I+n5ErjFCKp1IedVxx1eMEfecTwrQZuUwLISIyjqF
+czSJNwcNqzCx67z397/Cg5K/0pu6uIe0r7xozcbvAoGBAOOvZ9CDVPOg+rdXQvFm
+Jqou9lUPonNtOkUlgjl+qfAnK5q0KxvHSgxoWYO1bLOuAybQlbuBmSCPcKd5MMa9
+f/eRN9YetfVQ83Mz6YshBDJ22EFRUz+p7eeIY6dFp/PCvmO8Gq/qlA996dglBtmf
+RuG+T0vQT0mZgbWaGuBHfkwFAoGBAMOLg1MRxgKRMKavk6pU3EfyP3+J5XemWCDI
+1WLtbgV5uClNmzmxBBGypQHs7jbzKPtHpULn5kB+HzdVb0clG8ZDsK7u6s5OF0pO
+sBS+oVl7rF/eSeFcFhUYP26ZhsbWo3z/bERuj926VO2AxDPRTsP5o3pQPGZhY0V9
+irGgbUJrAoGBAOseS3J4BqYM4R3Hr7cRAhvzSjIkeTcDF1zTOa4FZDHBxZ6g2PNq
+8ekhtfn1zPczsPTF1vNuqEISKLxaPkVPiw0mtaZQjVwpF/IOxMNjWVLp6oJf8Mm2
+BxlXqPnQ4mG66oqSFQgDEmFdMhRb2of6xL1gYYL62C80G2T7QtmPfSab
+-----END RSA PRIVATE KEY-----
+";
+
+ private string? _tempDir;
+ private string? _accountsFile;
+
+ [TestInitialize]
+ public void Init()
+ {
+ IdentityTokenStorage.Reset();
+ _tempDir = Path.Combine(Path.GetTempPath(), "LibationTokenStorageTests-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempDir);
+ _accountsFile = Path.Combine(_tempDir, "AccountsSettings.json");
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ IdentityTokenStorage.Reset();
+ Configuration.RestoreSingletonInstance();
+
+ if (_tempDir is not null && Directory.Exists(_tempDir))
+ {
+ try { Directory.Delete(_tempDir, recursive: true); }
+ catch { /* best effort */ }
+ }
+ }
+
+ [TestMethod]
+ public void Apply_Encrypted_configures_write_method_and_protector_when_store_available()
+ {
+ var config = Configuration.CreateMockInstance();
+ config.TokenStorageMethod = TokenStorageMethod.Encrypted;
+
+ IdentityTokenStorageWiring.Apply(config);
+
+ Assert.AreEqual(TokenStorageMethod.Encrypted, IdentityTokenStorage.WriteMethod);
+ if (IdentityTokenStorageWiring.IsOsSecretStoreAvailable(out _))
+ Assert.IsNotNull(IdentityTokenStorage.Protector);
+ }
+
+ [TestMethod]
+ public void Apply_Plaintext_configures_plaintext_writes()
+ {
+ var config = Configuration.CreateMockInstance();
+ config.TokenStorageMethod = TokenStorageMethod.Plaintext;
+
+ IdentityTokenStorageWiring.Apply(config);
+
+ Assert.AreEqual(TokenStorageMethod.Plaintext, IdentityTokenStorage.WriteMethod);
+ }
+
+ [TestMethod]
+ public void Changing_preference_updates_write_method_without_converting_existing_tokens()
+ {
+ WriteLegacyAccountsFile(_accountsFile!);
+ var before = File.ReadAllText(_accountsFile!);
+
+ var config = Configuration.CreateMockInstance();
+ config.TokenStorageMethod = TokenStorageMethod.Plaintext;
+ IdentityTokenStorageWiring.Apply(config);
+
+ config.TokenStorageMethod = TokenStorageMethod.Encrypted;
+
+ Assert.AreEqual(TokenStorageMethod.Encrypted, IdentityTokenStorage.WriteMethod);
+ File.ReadAllText(_accountsFile!).Should().Be(before);
+ Assert.AreEqual(
+ TokenStorageAlignment.SomeMismatch,
+ AccountTokenStorage.GetAccountsAlignment(TokenStorageMethod.Encrypted, _accountsFile));
+ Assert.AreEqual(
+ TokenStorageAlignment.AllMatch,
+ AccountTokenStorage.GetAccountsAlignment(TokenStorageMethod.Plaintext, _accountsFile));
+ }
+
+ [TestMethod]
+ public void New_identity_serialize_uses_configured_write_method()
+ {
+ var store = new MemoryOsSecretStore();
+ var protector = new AesGcmSecretProtector(store, "libation-wiring-tests-master-key");
+ IdentityTokenStorage.Configure(TokenStorageMethod.Encrypted, protector);
+
+ var identity = CreateRegisteredIdentity();
+ var jo = JObject.Parse(JsonConvert.SerializeObject(identity, Identity.GetJsonSerializerSettings()));
+
+ jo.SelectTokens("$..IsEncrypted").Should().HaveCount(6);
+ jo["ExistingAccessToken"]!["IsEncrypted"]!.Value().Should().BeTrue();
+ }
+
+ [TestMethod]
+ public void ConvertAllAccounts_is_explicit_and_aligns_file()
+ {
+ WriteLegacyAccountsFile(_accountsFile!);
+
+ var store = new MemoryOsSecretStore();
+ IdentityTokenStorage.Configure(TokenStorageMethod.Encrypted, new AesGcmSecretProtector(store, "libation-convert-tests"));
+
+ var result = AccountTokenStorage.ConvertAllAccounts(TokenStorageMethod.Encrypted, _accountsFile);
+ result.Succeeded.Should().BeTrue();
+ result.Changed.Should().BeTrue();
+
+ Assert.AreEqual(
+ TokenStorageAlignment.AllMatch,
+ AccountTokenStorage.GetAccountsAlignment(TokenStorageMethod.Encrypted, _accountsFile));
+
+ var after = JObject.Parse(File.ReadAllText(_accountsFile!));
+ after.SelectTokens("$..IsEncrypted").Should().HaveCount(6);
+ }
+
+ private static void WriteLegacyAccountsFile(string path)
+ {
+ var identity = CreateRegisteredIdentity();
+ IdentityTokenStorage.Configure(TokenStorageMethod.Plaintext, protector: null);
+ var identityJson = JsonConvert.SerializeObject(identity, Identity.GetJsonSerializerSettings());
+
+ var root = new JObject
+ {
+ ["Accounts"] = new JArray
+ {
+ new JObject
+ {
+ ["AccountId"] = "user@example.com",
+ ["AccountName"] = "Test",
+ ["LibraryScan"] = true,
+ ["DecryptKey"] = "",
+ ["IdentityTokens"] = JObject.Parse(identityJson)
+ }
+ },
+ ["Cdm"] = null
+ };
+ File.WriteAllText(path, root.ToString(Formatting.Indented));
+ }
+
+ private static Identity CreateRegisteredIdentity()
+ {
+ var identity = new Identity(Localization.Get("us"));
+ identity.Update(
+ new PrivateKey(SamplePrivateKey),
+ new AdpToken(SampleAdpToken),
+ new AccessToken(SampleAccessToken, SampleExpires),
+ new RefreshToken(SampleRefreshToken),
+ new List> { new(SampleCookieName, SampleCookieValue) },
+ deviceSerialNumber: "device-serial",
+ deviceType: "device-type",
+ amazonAccountId: "amzn-account",
+ deviceName: "device-name",
+ storeAuthenticationCookie: SampleStoreAuthCookie);
+ return identity;
+ }
+}
diff --git a/Source/_Tests/LibationFileManager.Tests/TokenStorageMethodConfigurationTests.cs b/Source/_Tests/LibationFileManager.Tests/TokenStorageMethodConfigurationTests.cs
new file mode 100644
index 00000000..1201d9cb
--- /dev/null
+++ b/Source/_Tests/LibationFileManager.Tests/TokenStorageMethodConfigurationTests.cs
@@ -0,0 +1,63 @@
+using AssertionHelper;
+using AudibleApi.Authorization;
+using FileManager;
+using LibationFileManager;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Newtonsoft.Json.Linq;
+
+namespace TokenStorageMethodConfigurationTests;
+
+[TestClass]
+[DoNotParallelize]
+public class TokenStorageMethodConfigurationTests
+{
+ [TestCleanup]
+ public void Cleanup()
+ {
+ Configuration.RestoreSingletonInstance();
+ }
+
+ [TestMethod]
+ public void Default_when_missing_is_Encrypted()
+ {
+ var config = Configuration.CreateMockInstance();
+
+ config.Exists(nameof(Configuration.TokenStorageMethod)).Should().BeFalse();
+ Assert.AreEqual(TokenStorageMethod.Encrypted, config.TokenStorageMethod);
+ }
+
+ [TestMethod]
+ public void Round_trips_Plaintext_and_Encrypted()
+ {
+ var config = Configuration.CreateMockInstance();
+
+ config.TokenStorageMethod = TokenStorageMethod.Plaintext;
+ Assert.AreEqual(TokenStorageMethod.Plaintext, config.TokenStorageMethod);
+ Assert.AreEqual(TokenStorageMethod.Plaintext, config.CreateEphemeralCopy().TokenStorageMethod);
+
+ config.TokenStorageMethod = TokenStorageMethod.Encrypted;
+ Assert.AreEqual(TokenStorageMethod.Encrypted, config.TokenStorageMethod);
+ Assert.AreEqual(TokenStorageMethod.Encrypted, config.CreateEphemeralCopy().TokenStorageMethod);
+ }
+
+ [TestMethod]
+ public void Unknown_enum_value_resolves_to_Encrypted_not_Plaintext()
+ {
+ // Same UpCast path used by Settings.json deserialization for enum settings.
+ var parsed = IJsonBackedDictionary.UpCast(new JValue("NotARealMethod"));
+
+ Assert.AreEqual(TokenStorageMethod.Encrypted, parsed);
+ Assert.AreNotEqual(TokenStorageMethod.Plaintext, parsed);
+ }
+
+ [TestMethod]
+ public void String_enum_names_round_trip_via_UpCast()
+ {
+ Assert.AreEqual(
+ TokenStorageMethod.Plaintext,
+ IJsonBackedDictionary.UpCast(new JValue("Plaintext")));
+ Assert.AreEqual(
+ TokenStorageMethod.Encrypted,
+ IJsonBackedDictionary.UpCast(new JValue("Encrypted")));
+ }
+}