mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-07-29 16:36:00 -04:00
Add TokenStorageMethod preference and AudibleApi token-storage wiring
This commit is contained in:
@@ -96,6 +96,7 @@ public static class LibationScaffolding
|
||||
}
|
||||
DeleteOpenSqliteFiles(config);
|
||||
AudibleApiStorage.EnsureAccountsSettingsFileExists();
|
||||
IdentityTokenStorageWiring.Apply(config);
|
||||
|
||||
//
|
||||
// migrations go below here
|
||||
|
||||
59
Source/AudibleUtilities/AccountTokenStorage.cs
Normal file
59
Source/AudibleUtilities/AccountTokenStorage.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
76
Source/AudibleUtilities/IdentityTokenStorageWiring.cs
Normal file
76
Source/AudibleUtilities/IdentityTokenStorageWiring.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user