Add TokenStorageMethod preference and AudibleApi token-storage wiring

This commit is contained in:
Robert McRackan
2026-07-25 21:17:11 -04:00
parent a9e6f75443
commit bb18e9adc4
6 changed files with 411 additions and 0 deletions

View File

@@ -96,6 +96,7 @@ public static class LibationScaffolding
}
DeleteOpenSqliteFiles(config);
AudibleApiStorage.EnsureAccountsSettingsFileExists();
IdentityTokenStorageWiring.Apply(config);
//
// migrations go below here

View File

@@ -0,0 +1,59 @@
using AudibleApi.Authorization;
namespace AudibleUtilities;
/// <summary>
/// Alignment and conversion helpers for identity tokens in <c>AccountsSettings.json</c>.
/// Preference changes alone do not convert; conversion is explicit via <see cref="ConvertAllAccounts"/>.
/// </summary>
public static class AccountTokenStorage
{
/// <summary>
/// Compare persisted encryption metadata for all account identities to <paramref name="method"/>.
/// </summary>
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<TokenStorageAlignment>();
foreach (var account in persister.AccountsSettings.Accounts)
{
if (account.IdentityTokens is null)
continue;
alignments.Add(IdentityTokenConversion.GetAlignment(account.IdentityTokens, method));
}
return CombineAlignments(alignments);
}
/// <summary>
/// Convert every identity in the accounts settings file to <paramref name="method"/>.
/// All-or-nothing: failure leaves the original file unchanged.
/// </summary>
public static IdentityTokenConversionResult ConvertAllAccounts(TokenStorageMethod method, string? accountsSettingsFile = null)
{
var path = accountsSettingsFile ?? AudibleApiStorage.AccountsSettingsFile;
return IdentityTokenConversion.ConvertAllIdentitiesInFile(path, method);
}
private static TokenStorageAlignment CombineAlignments(IReadOnlyList<TokenStorageAlignment> 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;
}
}

View File

@@ -0,0 +1,76 @@
using AudibleApi.Authorization;
using Dinah.Core.Security;
using LibationFileManager;
using System.ComponentModel;
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";
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 = 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);
}
/// <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;
}
private static void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(Configuration.TokenStorageMethod))
return;
if (sender is not Configuration config)
return;
ConfigureFrom(config);
}
}

View File

@@ -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); }

View File

@@ -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<bool>().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<KeyValuePair<string, string?>> { new(SampleCookieName, SampleCookieValue) },
deviceSerialNumber: "device-serial",
deviceType: "device-type",
amazonAccountId: "amzn-account",
deviceName: "device-name",
storeAuthenticationCookie: SampleStoreAuthCookie);
return identity;
}
}

View File

@@ -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<TokenStorageMethod>(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<TokenStorageMethod>(new JValue("Plaintext")));
Assert.AreEqual(
TokenStorageMethod.Encrypted,
IJsonBackedDictionary.UpCast<TokenStorageMethod>(new JValue("Encrypted")));
}
}