From 0dfee1ff5c267ed9ecf27006d91328fa7459fe5c Mon Sep 17 00:00:00 2001 From: Chris Clements Date: Fri, 31 Jul 2026 17:24:53 -0500 Subject: [PATCH 001/317] Update AAXClean.Codecs to 3.1.0 Fixes xHE-AAC (USAC) seeking in Apple players: AAXClean now writes the sync sample table (stss) required by ISO/IEC 23003-3 for USAC output, and chapter-split files are sample-accurate via edit lists (Mbucari/AAXClean#18, Mbucari/AAXClean#19). Co-Authored-By: Claude Fable 5 --- Source/AaxDecrypter/AaxDecrypter.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/AaxDecrypter/AaxDecrypter.csproj b/Source/AaxDecrypter/AaxDecrypter.csproj index bd043b8a2..67a98facb 100644 --- a/Source/AaxDecrypter/AaxDecrypter.csproj +++ b/Source/AaxDecrypter/AaxDecrypter.csproj @@ -14,7 +14,7 @@ - + From f35530d18bf3f00152ba637086a4f70e0f0ff66c Mon Sep 17 00:00:00 2001 From: Robert McRackan Date: Sat, 1 Aug 2026 09:19:00 -0400 Subject: [PATCH 002/317] warn that Windows-encrypted tokens fail in Docker --- docs/advanced/troubleshoot.md | 14 ++++++++++++++ docs/frequently-asked-questions.md | 10 ++++++++++ docs/installation/docker.md | 3 +++ 3 files changed, 27 insertions(+) diff --git a/docs/advanced/troubleshoot.md b/docs/advanced/troubleshoot.md index 0988f63e4..dea9076b5 100644 --- a/docs/advanced/troubleshoot.md +++ b/docs/advanced/troubleshoot.md @@ -23,6 +23,20 @@ Audible returned an HTML page instead of JSON. Common causes: transient outage, 3. Disable VPN/proxy and scan again. 4. Remove and re-add the account in Libation. +## Failed to decrypt ExistingAccessToken (Docker finds no new books) + +**Symptoms:** The Windows (or other desktop) app shows your full library and new titles, but Docker / Linux finds no new books after you copy `AccountsSettings.json` from that machine. Container or `Libation.log` output includes `Failed to decrypt ExistingAccessToken`. + +**Cause:** Recent Libation can encrypt auth tokens in `AccountsSettings.json` using a key stored in the OS secret store (on Windows: DPAPI). That key does **not** travel when you copy the file into Docker, so the container cannot decrypt the tokens and the library scan fails. + +**Quick check:** Open `AccountsSettings.json` on the Docker config volume. If you see `"IsEncrypted": true` near `ExistingAccessToken`, `RefreshToken`, or related fields, that is the problem. + +**Fix from Windows:** In the desktop app, open **Settings -> Important**, uncheck **Store authentication tokens encrypted**, and when prompted choose **Yes** to decrypt and re-save existing tokens as plaintext. Copy the updated `AccountsSettings.json` (and `Settings.json`) into the Docker config folder and restart the container. + +**Fix without copying Windows accounts:** Create or refresh credentials inside Docker with `login-external` or `import-account`. See [Docker - Adding Audible accounts without the GUI](/docs/installation/docker#adding-audible-accounts-without-the-gui). + +Also listed under [Docker Troubleshooting](/docs/installation/docker#troubleshooting) and the [FAQ](/docs/frequently-asked-questions#docker-finds-no-new-books--failed-to-decrypt-existingaccesstoken). + ## Login fails for an old pre-Amazon Audible account If your Audible account predates Amazon and login fails when you use an email or a normal region, choose a **pre-amazon** locale and enter your old **username** in the Audible email/login field. See the [FAQ](/docs/frequently-asked-questions#my-audible-account-is-from-before-amazon---how-do-i-add-it). diff --git a/docs/frequently-asked-questions.md b/docs/frequently-asked-questions.md index 36ae8fc7e..b4f887eef 100644 --- a/docs/frequently-asked-questions.md +++ b/docs/frequently-asked-questions.md @@ -55,6 +55,16 @@ If you enabled the [Request xHE-AAC Codec](./features/audio-file-formats.md#requ For reasons known only to Jeff Bezos and God, amazon and audible brazil handle logins slightly differently. The external browser login option is not possible for Brazil. [See this ticket for more details.](https://github.com/rmcrackan/Libation/issues/1103) +## Docker finds no new books / Failed to decrypt ExistingAccessToken + +The Windows app sees your library (including new books), but Docker does not, and the log shows `Failed to decrypt ExistingAccessToken`. + +You likely copied an `AccountsSettings.json` that has **encrypted** tokens (`"IsEncrypted": true`). The decryption key stays on the Windows machine and is not in the file, so Docker cannot use those credentials. + +**Fix:** On Windows, **Settings -> Important**, uncheck **Store authentication tokens encrypted**, confirm converting existing tokens to plaintext, then copy the updated file into your Docker config and restart. Or re-login inside the container with `login-external` / `import-account`. + +Full steps: [Troubleshooting - Failed to decrypt ExistingAccessToken](/docs/advanced/troubleshoot#failed-to-decrypt-existingaccesstoken-docker-finds-no-new-books) and [Docker Troubleshooting](/docs/installation/docker#troubleshooting). + ## Snap refreshed and Libation crashes on the database - what should I check? Snap keeps per-version folders under `~/snap/libation//`. After an update, `appsettings.json` in the **new** folder may still list `"LibationFiles"` with an **old** revision path. Libation then opens the wrong directory and you can see errors about SQLite or migrations even when permissions look fine. diff --git a/docs/installation/docker.md b/docs/installation/docker.md index a8112245c..8690a785e 100644 --- a/docs/installation/docker.md +++ b/docs/installation/docker.md @@ -20,6 +20,8 @@ The docker image is provided as-is. We hope it can be useful to you but it is no Configuration in Libation is handled by two files, `AccountsSettings.json` and `Settings.json`. These files can usually be found in the Libation folder in your user's home directory. The easiest way to configure these is to run the desktop version of Libation and then copy them into a folder, such as `/opt/libation/config`, that you'll volume mount into the image. `Settings.json` is technically optional, and, if not provided, Libation will run using the default settings. Additionally, the `Books` and `InProgress` settings in `Settings.json` will be ignored and the image will instead substitute it's own values. +If you copy `AccountsSettings.json` from Windows and the desktop app is storing tokens encrypted, Docker cannot decrypt them (the encryption key stays on the Windows machine). See [Troubleshooting](#troubleshooting), the site-wide [Troubleshooting](/docs/advanced/troubleshoot#failed-to-decrypt-existingaccesstoken-docker-finds-no-new-books) page, or the [FAQ](/docs/frequently-asked-questions#docker-finds-no-new-books--failed-to-decrypt-existingaccesstoken). + ### Adding Audible accounts without the GUI If you run Libation on a server or in Docker and do not want to copy `AccountsSettings.json` from a desktop install, you can create or update accounts with LibationCli (same binary as in the image under `/libation/LibationCli`): @@ -95,6 +97,7 @@ If the user it's running as is correct, and it still cannot write, be sure to ch ## Troubleshooting +- **`Failed to decrypt ExistingAccessToken` (scan finds no books after copying Windows config):** Encrypted tokens from a Windows desktop install cannot be decrypted in Docker. Full symptoms, checks, and fixes: [Troubleshooting - Failed to decrypt ExistingAccessToken](/docs/advanced/troubleshoot#failed-to-decrypt-existingaccesstoken-docker-finds-no-new-books) and [FAQ](/docs/frequently-asked-questions#docker-finds-no-new-books--failed-to-decrypt-existingaccesstoken). Short version: on Windows, **Settings -> Important**, uncheck **Store authentication tokens encrypted**, convert existing tokens to plaintext, re-copy `AccountsSettings.json`, or use `login-external` / `import-account` inside the container ([above](#adding-audible-accounts-without-the-gui)). - **Library scan appears to hang in Docker:** Try setting `ImportEpisodes` to `false` in `Settings.json` on your config volume and run again. That turns off the extra episode/podcast catalog requests during import and helps narrow down whether the stall is in that path versus auth or the network. ## Advanced Database Options From 79b6521a4e862a54ca9810f0a9a497d3c197bec4 Mon Sep 17 00:00:00 2001 From: Robert McRackan Date: Sat, 1 Aug 2026 09:38:50 -0400 Subject: [PATCH 003/317] surfaced encrypted-token decrypt failure for Docker and CLI --- Docker/liberate.sh | 5 + .../AccountSettingsDecryptFailure.cs | 91 +++++++++++++++++++ .../Views/MainWindow.axaml.cs | 27 ++++-- Source/LibationCli/Options/_OptionsBase.cs | 10 ++ Source/LibationWinForms/Form1._NonUI.cs | 27 ++++-- .../AccountSettingsDecryptFailureTests.cs | 63 +++++++++++++ docs/advanced/troubleshoot.md | 2 +- docs/installation/docker.md | 14 ++- 8 files changed, 216 insertions(+), 23 deletions(-) create mode 100644 Source/AudibleUtilities/AccountSettingsDecryptFailure.cs create mode 100644 Source/_Tests/AudibleUtilities.Tests/AccountSettingsDecryptFailureTests.cs diff --git a/Docker/liberate.sh b/Docker/liberate.sh index 9eaa6888d..9ae7f7fca 100755 --- a/Docker/liberate.sh +++ b/Docker/liberate.sh @@ -141,6 +141,11 @@ setup_db() { run() { info "scanning accounts" /libation/LibationCli scan + local scan_exit=$? + if [ "${scan_exit}" -ne 0 ]; then + error "scan failed (exit ${scan_exit}); skipping liberate. If the log shows Failed to decrypt ExistingAccessToken, see https://getlibation.com/docs/frequently-asked-questions#docker-finds-no-new-books-failed-to-decrypt-existingaccesstoken" + exit "${scan_exit}" + fi info "liberating books" /libation/LibationCli liberate } diff --git a/Source/AudibleUtilities/AccountSettingsDecryptFailure.cs b/Source/AudibleUtilities/AccountSettingsDecryptFailure.cs new file mode 100644 index 000000000..8f89c0336 --- /dev/null +++ b/Source/AudibleUtilities/AccountSettingsDecryptFailure.cs @@ -0,0 +1,91 @@ +using Newtonsoft.Json; + +namespace AudibleUtilities; + +/// +/// Libation-facing helpers when AccountsSettings.json contains encrypted tokens +/// that cannot be unlocked on this OS/machine (common when copying Windows config into Docker). +/// +public static class AccountSettingsDecryptFailure +{ + public const string LoadErrorCaption = "Error Loading Account Settings"; + + public const string FaqUrl + = "https://getlibation.com/docs/frequently-asked-questions#docker-finds-no-new-books-failed-to-decrypt-existingaccesstoken"; + + private const string MessagePrefix = "Failed to decrypt"; + + private static readonly string[] ThingsToTryBullets = + [ + "On the machine that created the encrypted file (usually Windows): Settings -> Important, uncheck \"Store authentication tokens encrypted\", convert existing tokens to plaintext when prompted, then copy the updated AccountsSettings.json again.", + "Or re-authenticate on this machine/container with LibationCli login-external or import-account (do not rely on the encrypted Windows copy).", + $"Details: {FaqUrl}", + ]; + + /// + /// True when (or an inner exception) is a decrypt failure from identity token JSON. + /// + public static bool TryFindInTree(Exception ex, out JsonReaderException? match) + { + ArgumentNullException.ThrowIfNull(ex); + JsonReaderException? found = null; + walk(ex); + match = found; + return found is not null; + + void walk(Exception? e) + { + if (e is null || found is not null) + return; + + if (e is JsonReaderException jsonEx + && jsonEx.Message.StartsWith(MessagePrefix, StringComparison.Ordinal)) + { + found = jsonEx; + return; + } + + if (e is AggregateException agg) + { + foreach (var inner in agg.InnerExceptions) + walk(inner); + } + + walk(e.InnerException); + } + } + + public static IEnumerable GetExplainerLines(Exception ex) + { + ArgumentNullException.ThrowIfNull(ex); + + yield return "Encrypted authentication tokens in AccountsSettings.json could not be decrypted on this machine."; + yield return "The encryption key is stored in the OS secret store where the tokens were encrypted (for example Windows DPAPI) and does not travel when you copy the file to Docker or another computer."; + if (TryFindInTree(ex, out var decryptEx) && decryptEx is not null) + yield return $"Underlying error: {decryptEx.Message.TrimEnd('.')}."; + yield return string.Empty; + yield return "Things to try:"; + foreach (var bullet in ThingsToTryBullets) + yield return "• " + bullet; + } + + public static string GetExplainerBody(Exception ex) + => string.Join("\r\n", GetExplainerLines(ex)); + + /// + /// Dialog body after backup-and-empty recovery when load failed due to token decrypt. + /// + public static string GetRecoveredDialogBody(Exception ex, string backupPath) + { + ArgumentNullException.ThrowIfNull(ex); + ArgumentException.ThrowIfNullOrWhiteSpace(backupPath); + + return $""" + {GetExplainerBody(ex)} + + Libation created a new, empty account settings file so the app can start. You will need to re-add your Audible account(s) (or copy a plaintext AccountsSettings.json) before scanning or downloading. + + The previous account settings file was archived at '{backupPath}' + """; + } +} diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.axaml.cs index 3c83321be..5a6e02c2f 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml.cs +++ b/Source/LibationAvalonia/Views/MainWindow.axaml.cs @@ -116,18 +116,27 @@ private void AudibleApiStorage_LoadError(object? sender, AccountSettingsLoadErro } async void showAccountSettingsRecoveredMessage(LongPath backupFile) - => await MessageBox.Show(this, $""" - Libation could not load your account settings, so it had created a new, empty account settings file. + { + var ex = e.GetException(); + var body = AccountSettingsDecryptFailure.TryFindInTree(ex, out _) + ? AccountSettingsDecryptFailure.GetRecoveredDialogBody(ex, backupFile.PathWithoutPrefix) + : $""" + Libation could not load your account settings, so it had created a new, empty account settings file. - You will need to re-add you Audible account(s) before scanning or downloading. + You will need to re-add you Audible account(s) before scanning or downloading. - The old account settings file has been archived at '{backupFile.PathWithoutPrefix}' + The old account settings file has been archived at '{backupFile.PathWithoutPrefix}' - {e.GetException().ToString()} - """, - "Error Loading Account Settings", - MessageBoxButtons.OK, - MessageBoxIcon.Warning); + {ex} + """; + + await MessageBox.Show( + this, + body, + AccountSettingsDecryptFailure.LoadErrorCaption, + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + } void showAccountSettingsUnrecoveredMessage() { diff --git a/Source/LibationCli/Options/_OptionsBase.cs b/Source/LibationCli/Options/_OptionsBase.cs index 8610e1348..a7dd99a56 100644 --- a/Source/LibationCli/Options/_OptionsBase.cs +++ b/Source/LibationCli/Options/_OptionsBase.cs @@ -54,6 +54,16 @@ public async Task Run() return; } + if (AccountSettingsDecryptFailure.TryFindInTree(ex, out var decryptEx) && decryptEx is not null) + { + Console.Error.WriteLine("ERROR"); + Console.Error.WriteLine("====="); + foreach (var line in AccountSettingsDecryptFailure.GetExplainerLines(ex)) + Console.Error.WriteLine(line); + Serilog.Log.Logger.Error(decryptEx, "Failed to decrypt account settings tokens"); + return; + } + PrintVerbUsage( "ERROR", "=====", diff --git a/Source/LibationWinForms/Form1._NonUI.cs b/Source/LibationWinForms/Form1._NonUI.cs index a57087351..6ba1cbd28 100644 --- a/Source/LibationWinForms/Form1._NonUI.cs +++ b/Source/LibationWinForms/Form1._NonUI.cs @@ -72,18 +72,27 @@ private void AudibleApiStorage_LoadError(object? sender, AccountSettingsLoadErro } void showAccountSettingsRecoveredMessage(LongPath backupFile) - => MessageBox.Show(this, $""" - Libation could not load your account settings, so it had created a new, empty account settings file. + { + var ex = e.GetException(); + var body = AccountSettingsDecryptFailure.TryFindInTree(ex, out _) + ? AccountSettingsDecryptFailure.GetRecoveredDialogBody(ex, backupFile.PathWithoutPrefix) + : $""" + Libation could not load your account settings, so it had created a new, empty account settings file. - You will need to re-add you Audible account(s) before scanning or downloading. + You will need to re-add you Audible account(s) before scanning or downloading. - The old account settings file has been archived at '{backupFile.PathWithoutPrefix}' + The old account settings file has been archived at '{backupFile.PathWithoutPrefix}' - {e.GetException().ToString()} - """, - "Error Loading Account Settings", - MessageBoxButtons.OK, - MessageBoxIcon.Warning); + {ex} + """; + + MessageBox.Show( + this, + body, + AccountSettingsDecryptFailure.LoadErrorCaption, + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + } void showAccountSettingsUnrecoveredMessage() => MessageBox.Show(this, $""" diff --git a/Source/_Tests/AudibleUtilities.Tests/AccountSettingsDecryptFailureTests.cs b/Source/_Tests/AudibleUtilities.Tests/AccountSettingsDecryptFailureTests.cs new file mode 100644 index 000000000..5b940ca87 --- /dev/null +++ b/Source/_Tests/AudibleUtilities.Tests/AccountSettingsDecryptFailureTests.cs @@ -0,0 +1,63 @@ +using AudibleUtilities; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using System; + +namespace AccountSettingsDecryptFailureTests; + +[TestClass] +public class AccountSettingsDecryptFailureTests +{ + [TestMethod] + public void TryFindInTree_matches_Failed_to_decrypt_JsonReaderException() + { + var inner = new JsonReaderException("Failed to decrypt ExistingAccessToken."); + var outer = new InvalidOperationException("load failed", inner); + + Assert.IsTrue(AccountSettingsDecryptFailure.TryFindInTree(outer, out var match)); + Assert.AreSame(inner, match); + } + + [TestMethod] + public void TryFindInTree_matches_inside_AggregateException() + { + var decrypt = new JsonReaderException("Failed to decrypt RefreshToken."); + var agg = new AggregateException(new Exception("other"), decrypt); + + Assert.IsTrue(AccountSettingsDecryptFailure.TryFindInTree(agg, out var match)); + Assert.AreSame(decrypt, match); + } + + [TestMethod] + public void TryFindInTree_ignores_unrelated_JsonReaderException() + { + var ex = new JsonReaderException("Unexpected character encountered while parsing value."); + Assert.IsFalse(AccountSettingsDecryptFailure.TryFindInTree(ex, out var match)); + Assert.IsNull(match); + } + + [TestMethod] + public void Explainer_mentions_plaintext_convert_cli_and_faq() + { + var ex = new JsonReaderException("Failed to decrypt ExistingAccessToken."); + var body = AccountSettingsDecryptFailure.GetExplainerBody(ex); + + StringAssert.Contains(body, "could not be decrypted"); + StringAssert.Contains(body, "Store authentication tokens encrypted"); + StringAssert.Contains(body, "login-external"); + StringAssert.Contains(body, "import-account"); + StringAssert.Contains(body, AccountSettingsDecryptFailure.FaqUrl); + StringAssert.Contains(body, "ExistingAccessToken"); + } + + [TestMethod] + public void Recovered_dialog_includes_backup_path_and_explainer() + { + var ex = new JsonReaderException("Failed to decrypt ExistingAccessToken."); + var body = AccountSettingsDecryptFailure.GetRecoveredDialogBody(ex, @"C:\tmp\AccountsSettings.json.bak"); + + StringAssert.Contains(body, "empty account settings file"); + StringAssert.Contains(body, @"C:\tmp\AccountsSettings.json.bak"); + StringAssert.Contains(body, AccountSettingsDecryptFailure.FaqUrl); + } +} diff --git a/docs/advanced/troubleshoot.md b/docs/advanced/troubleshoot.md index dea9076b5..e7d58c09b 100644 --- a/docs/advanced/troubleshoot.md +++ b/docs/advanced/troubleshoot.md @@ -35,7 +35,7 @@ Audible returned an HTML page instead of JSON. Common causes: transient outage, **Fix without copying Windows accounts:** Create or refresh credentials inside Docker with `login-external` or `import-account`. See [Docker - Adding Audible accounts without the GUI](/docs/installation/docker#adding-audible-accounts-without-the-gui). -Also listed under [Docker Troubleshooting](/docs/installation/docker#troubleshooting) and the [FAQ](/docs/frequently-asked-questions#docker-finds-no-new-books--failed-to-decrypt-existingaccesstoken). +Also listed under [Docker Troubleshooting](/docs/installation/docker#troubleshooting) and the [FAQ](/docs/frequently-asked-questions#docker-finds-no-new-books-failed-to-decrypt-existingaccesstoken). ## Login fails for an old pre-Amazon Audible account diff --git a/docs/installation/docker.md b/docs/installation/docker.md index 8690a785e..d3325abdf 100644 --- a/docs/installation/docker.md +++ b/docs/installation/docker.md @@ -12,15 +12,21 @@ The docker image is provided as-is. We hope it can be useful to you but it is no ## Configuration +> [!WARNING] Encrypted tokens from Windows will not work in Docker +> +> If Docker logs show `Failed to decrypt ExistingAccessToken` (or your scan finds no books after copying Windows config), you copied an `AccountsSettings.json` whose auth tokens are encrypted with a key that stays on the Windows machine. Docker cannot decrypt them. +> +> **Fix:** On Windows, **Settings -> Important**, uncheck **Store authentication tokens encrypted**, convert existing tokens to plaintext when prompted, then re-copy `AccountsSettings.json` into the Docker config folder. Or skip copying Windows accounts and use `login-external` / `import-account` inside the container (see below). +> +> Details: [FAQ](/docs/frequently-asked-questions#docker-finds-no-new-books-failed-to-decrypt-existingaccesstoken) · [Troubleshooting](/docs/advanced/troubleshoot#failed-to-decrypt-existingaccesstoken-docker-finds-no-new-books) + > [!WARNING] NTFS filesystem limitations > > NTFS filesystems (Windows, and NTFS-formatted external drives on Linux/Mac) do not support colons (`:`) in filenames. Since many audiobook titles contain colons (e.g., "Title: A Subtitle"), downloads may produce invalid filenames. > > **Solution:** Configure custom replacement characters in `Settings.json` to replace colons with compatible characters. See [Command Line Interface - Set custom replacement characters](/docs/advanced/command-line-interface#set-custom-replacement-characters) for configuration examples. -Configuration in Libation is handled by two files, `AccountsSettings.json` and `Settings.json`. These files can usually be found in the Libation folder in your user's home directory. The easiest way to configure these is to run the desktop version of Libation and then copy them into a folder, such as `/opt/libation/config`, that you'll volume mount into the image. `Settings.json` is technically optional, and, if not provided, Libation will run using the default settings. Additionally, the `Books` and `InProgress` settings in `Settings.json` will be ignored and the image will instead substitute it's own values. - -If you copy `AccountsSettings.json` from Windows and the desktop app is storing tokens encrypted, Docker cannot decrypt them (the encryption key stays on the Windows machine). See [Troubleshooting](#troubleshooting), the site-wide [Troubleshooting](/docs/advanced/troubleshoot#failed-to-decrypt-existingaccesstoken-docker-finds-no-new-books) page, or the [FAQ](/docs/frequently-asked-questions#docker-finds-no-new-books--failed-to-decrypt-existingaccesstoken). +Configuration in Libation is handled by two files, `AccountsSettings.json` and `Settings.json`. These files can usually be found in the Libation folder in your user's home directory. The easiest way to configure these is to run the desktop version of Libation and then copy them into a folder, such as `/opt/libation/config`, that you'll volume mount into the image. `Settings.json` is technically optional, and, if not provided, Libation will run using the default settings. Additionally, the `Books` and `InProgress` settings in `Settings.json` will be ignored and the image will instead substitute it's own values. If tokens in that file are encrypted on Windows, see the encrypted-tokens warning above. ### Adding Audible accounts without the GUI @@ -97,7 +103,7 @@ If the user it's running as is correct, and it still cannot write, be sure to ch ## Troubleshooting -- **`Failed to decrypt ExistingAccessToken` (scan finds no books after copying Windows config):** Encrypted tokens from a Windows desktop install cannot be decrypted in Docker. Full symptoms, checks, and fixes: [Troubleshooting - Failed to decrypt ExistingAccessToken](/docs/advanced/troubleshoot#failed-to-decrypt-existingaccesstoken-docker-finds-no-new-books) and [FAQ](/docs/frequently-asked-questions#docker-finds-no-new-books--failed-to-decrypt-existingaccesstoken). Short version: on Windows, **Settings -> Important**, uncheck **Store authentication tokens encrypted**, convert existing tokens to plaintext, re-copy `AccountsSettings.json`, or use `login-external` / `import-account` inside the container ([above](#adding-audible-accounts-without-the-gui)). +- **`Failed to decrypt ExistingAccessToken` (scan finds no books after copying Windows config):** See the [encrypted-tokens warning](#configuration) at the top of Configuration, plus [Troubleshooting](/docs/advanced/troubleshoot#failed-to-decrypt-existingaccesstoken-docker-finds-no-new-books) and the [FAQ](/docs/frequently-asked-questions#docker-finds-no-new-books-failed-to-decrypt-existingaccesstoken). - **Library scan appears to hang in Docker:** Try setting `ImportEpisodes` to `false` in `Settings.json` on your config volume and run again. That turns off the extra episode/podcast catalog requests during import and helps narrow down whether the stall is in that path versus auth or the network. ## Advanced Database Options From f12f5d42a1a7ef6d69c91f4694f7924c87d85a86 Mon Sep 17 00:00:00 2001 From: Robert McRackan Date: Sat, 1 Aug 2026 09:40:20 -0400 Subject: [PATCH 004/317] incr ver --- Source/AppScaffolding/AppScaffolding.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/AppScaffolding/AppScaffolding.csproj b/Source/AppScaffolding/AppScaffolding.csproj index 8db3bd3f9..9548f4fa6 100644 --- a/Source/AppScaffolding/AppScaffolding.csproj +++ b/Source/AppScaffolding/AppScaffolding.csproj @@ -2,7 +2,7 @@ net10.0 - 13.7.0 + 13.7.1 enable From 5f4a75aadef9e23351ea9ab77bf016c35bbc7660 Mon Sep 17 00:00:00 2001 From: Robert McRackan Date: Sat, 1 Aug 2026 11:25:56 -0400 Subject: [PATCH 005/317] Detect identity decrypt failures by exception type --- .../AccountSettingsDecryptFailure.cs | 13 +++++------ .../AudibleUtilities/AudibleUtilities.csproj | 2 +- Source/DataLayer/DataLayer.csproj | 4 ++-- Source/FileManager/FileManager.csproj | 2 +- .../LibationFileManager.csproj | 2 +- .../LibationWinForms/LibationWinForms.csproj | 2 +- .../CrossPlatformClientExe.csproj | 2 +- .../AccountSettingsDecryptFailureTests.cs | 22 ++++++++++++++----- 8 files changed, 29 insertions(+), 20 deletions(-) diff --git a/Source/AudibleUtilities/AccountSettingsDecryptFailure.cs b/Source/AudibleUtilities/AccountSettingsDecryptFailure.cs index 8f89c0336..80e70b728 100644 --- a/Source/AudibleUtilities/AccountSettingsDecryptFailure.cs +++ b/Source/AudibleUtilities/AccountSettingsDecryptFailure.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using AudibleApi.Authorization; namespace AudibleUtilities; @@ -13,8 +13,6 @@ public static class AccountSettingsDecryptFailure public const string FaqUrl = "https://getlibation.com/docs/frequently-asked-questions#docker-finds-no-new-books-failed-to-decrypt-existingaccesstoken"; - private const string MessagePrefix = "Failed to decrypt"; - private static readonly string[] ThingsToTryBullets = [ "On the machine that created the encrypted file (usually Windows): Settings -> Important, uncheck \"Store authentication tokens encrypted\", convert existing tokens to plaintext when prompted, then copy the updated AccountsSettings.json again.", @@ -25,10 +23,10 @@ public const string FaqUrl /// /// True when (or an inner exception) is a decrypt failure from identity token JSON. /// - public static bool TryFindInTree(Exception ex, out JsonReaderException? match) + public static bool TryFindInTree(Exception ex, out IdentityTokenDecryptException? match) { ArgumentNullException.ThrowIfNull(ex); - JsonReaderException? found = null; + IdentityTokenDecryptException? found = null; walk(ex); match = found; return found is not null; @@ -38,10 +36,9 @@ void walk(Exception? e) if (e is null || found is not null) return; - if (e is JsonReaderException jsonEx - && jsonEx.Message.StartsWith(MessagePrefix, StringComparison.Ordinal)) + if (e is IdentityTokenDecryptException decryptEx) { - found = jsonEx; + found = decryptEx; return; } diff --git a/Source/AudibleUtilities/AudibleUtilities.csproj b/Source/AudibleUtilities/AudibleUtilities.csproj index cfe943236..c3ced571a 100644 --- a/Source/AudibleUtilities/AudibleUtilities.csproj +++ b/Source/AudibleUtilities/AudibleUtilities.csproj @@ -7,7 +7,7 @@ - + diff --git a/Source/DataLayer/DataLayer.csproj b/Source/DataLayer/DataLayer.csproj index a39c918b4..d6ef31e20 100644 --- a/Source/DataLayer/DataLayer.csproj +++ b/Source/DataLayer/DataLayer.csproj @@ -11,8 +11,8 @@ - - + + diff --git a/Source/FileManager/FileManager.csproj b/Source/FileManager/FileManager.csproj index 148076a9d..41a592cba 100644 --- a/Source/FileManager/FileManager.csproj +++ b/Source/FileManager/FileManager.csproj @@ -6,7 +6,7 @@ - + diff --git a/Source/LibationFileManager/LibationFileManager.csproj b/Source/LibationFileManager/LibationFileManager.csproj index bcf337f98..6941f629d 100644 --- a/Source/LibationFileManager/LibationFileManager.csproj +++ b/Source/LibationFileManager/LibationFileManager.csproj @@ -6,7 +6,7 @@ - + diff --git a/Source/LibationWinForms/LibationWinForms.csproj b/Source/LibationWinForms/LibationWinForms.csproj index 3a5e37460..fec4ee348 100644 --- a/Source/LibationWinForms/LibationWinForms.csproj +++ b/Source/LibationWinForms/LibationWinForms.csproj @@ -41,7 +41,7 @@ - + diff --git a/Source/_Demos/LoadByOS/CrossPlatformClientExe/CrossPlatformClientExe.csproj b/Source/_Demos/LoadByOS/CrossPlatformClientExe/CrossPlatformClientExe.csproj index 09b787f6a..14884b168 100644 --- a/Source/_Demos/LoadByOS/CrossPlatformClientExe/CrossPlatformClientExe.csproj +++ b/Source/_Demos/LoadByOS/CrossPlatformClientExe/CrossPlatformClientExe.csproj @@ -19,7 +19,7 @@ - + diff --git a/Source/_Tests/AudibleUtilities.Tests/AccountSettingsDecryptFailureTests.cs b/Source/_Tests/AudibleUtilities.Tests/AccountSettingsDecryptFailureTests.cs index 5b940ca87..22b4289e6 100644 --- a/Source/_Tests/AudibleUtilities.Tests/AccountSettingsDecryptFailureTests.cs +++ b/Source/_Tests/AudibleUtilities.Tests/AccountSettingsDecryptFailureTests.cs @@ -1,3 +1,4 @@ +using AudibleApi.Authorization; using AudibleUtilities; using Microsoft.VisualStudio.TestTools.UnitTesting; using Newtonsoft.Json; @@ -9,9 +10,9 @@ namespace AccountSettingsDecryptFailureTests; public class AccountSettingsDecryptFailureTests { [TestMethod] - public void TryFindInTree_matches_Failed_to_decrypt_JsonReaderException() + public void TryFindInTree_matches_IdentityTokenDecryptException() { - var inner = new JsonReaderException("Failed to decrypt ExistingAccessToken."); + var inner = CreateDecryptException("ExistingAccessToken"); var outer = new InvalidOperationException("load failed", inner); Assert.IsTrue(AccountSettingsDecryptFailure.TryFindInTree(outer, out var match)); @@ -21,7 +22,7 @@ public void TryFindInTree_matches_Failed_to_decrypt_JsonReaderException() [TestMethod] public void TryFindInTree_matches_inside_AggregateException() { - var decrypt = new JsonReaderException("Failed to decrypt RefreshToken."); + var decrypt = CreateDecryptException("RefreshToken"); var agg = new AggregateException(new Exception("other"), decrypt); Assert.IsTrue(AccountSettingsDecryptFailure.TryFindInTree(agg, out var match)); @@ -37,9 +38,17 @@ public void TryFindInTree_ignores_unrelated_JsonReaderException() } [TestMethod] - public void Explainer_mentions_plaintext_convert_cli_and_faq() + public void TryFindInTree_ignores_JsonReaderException_with_old_message_prefix() { var ex = new JsonReaderException("Failed to decrypt ExistingAccessToken."); + Assert.IsFalse(AccountSettingsDecryptFailure.TryFindInTree(ex, out var match)); + Assert.IsNull(match); + } + + [TestMethod] + public void Explainer_mentions_plaintext_convert_cli_and_faq() + { + var ex = CreateDecryptException("ExistingAccessToken"); var body = AccountSettingsDecryptFailure.GetExplainerBody(ex); StringAssert.Contains(body, "could not be decrypted"); @@ -53,11 +62,14 @@ public void Explainer_mentions_plaintext_convert_cli_and_faq() [TestMethod] public void Recovered_dialog_includes_backup_path_and_explainer() { - var ex = new JsonReaderException("Failed to decrypt ExistingAccessToken."); + var ex = CreateDecryptException("ExistingAccessToken"); var body = AccountSettingsDecryptFailure.GetRecoveredDialogBody(ex, @"C:\tmp\AccountsSettings.json.bak"); StringAssert.Contains(body, "empty account settings file"); StringAssert.Contains(body, @"C:\tmp\AccountsSettings.json.bak"); StringAssert.Contains(body, AccountSettingsDecryptFailure.FaqUrl); } + + private static IdentityTokenDecryptException CreateDecryptException(string fieldName) + => new(fieldName, new InvalidOperationException("crypto failed")); } From 785ec7bf967bd671675553e77d0481a2ff39c5c7 Mon Sep 17 00:00:00 2001 From: Robert McRackan Date: Sat, 1 Aug 2026 11:39:51 -0400 Subject: [PATCH 006/317] Resolve portable master key from file or env before OS store --- .../IdentityTokenStorageWiring.cs | 119 +++++++++++++++++- .../IdentityTokenStorageWiringTests.cs | 100 +++++++++++++++ 2 files changed, 218 insertions(+), 1 deletion(-) diff --git a/Source/AudibleUtilities/IdentityTokenStorageWiring.cs b/Source/AudibleUtilities/IdentityTokenStorageWiring.cs index 45db7fb84..9f6abadf2 100644 --- a/Source/AudibleUtilities/IdentityTokenStorageWiring.cs +++ b/Source/AudibleUtilities/IdentityTokenStorageWiring.cs @@ -2,6 +2,7 @@ using Dinah.Core.Security; using LibationFileManager; using System.ComponentModel; +using System.Security.Cryptography; namespace AudibleUtilities; @@ -13,6 +14,15 @@ public static class IdentityTokenStorageWiring { public const string ApplicationName = "Libation"; + /// Env var: path to a raw 32-byte master key file (from export-master-key). + public const string MasterKeyFileEnvVar = "LIBATION_MASTER_KEY_FILE"; + + /// Env var: Base64-encoded 32-byte master key. + public const string MasterKeyEnvVar = "LIBATION_MASTER_KEY"; + + /// Default master key file name under the Libation files directory. + public const string DefaultMasterKeyFileName = "libation-master.key"; + private static Lock Gate { get; } = new(); private static Configuration? _wiredConfig; @@ -47,7 +57,7 @@ public static void ConfigureFrom(Configuration config) ArgumentNullException.ThrowIfNull(config); var method = config.TokenStorageMethod; - var store = OsSecretStore.Create(ApplicationName); + var store = ResolveSecretStore(config); AesGcmSecretProtector? protector = store.IsAvailable ? new AesGcmSecretProtector(store) : null; @@ -56,6 +66,30 @@ public static void ConfigureFrom(Configuration config) IdentityTokenStorage.Configure(method, protector); } + /// + /// Resolve the secret store used for the AES-GCM master key. + /// Priority: -> default under Libation files + /// -> -> OS-bound . + /// + 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()); + + return OsSecretStore.Create(ApplicationName); + } + /// True when the OS secret store can hold Libation's encryption master key. public static bool IsOsSecretStoreAvailable(out string? unavailableReason) { @@ -64,6 +98,67 @@ public static bool IsOsSecretStoreAvailable(out string? unavailableReason) return store.IsAvailable; } + /// True when any resolved secret store (portable or OS) can supply an encryption master key. + 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; + } + + 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)) @@ -73,4 +168,26 @@ private static void OnPropertyChanged(object? sender, PropertyChangedEventArgs e 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 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!); + } } diff --git a/Source/_Tests/AudibleUtilities.Tests/IdentityTokenStorageWiringTests.cs b/Source/_Tests/AudibleUtilities.Tests/IdentityTokenStorageWiringTests.cs index 87bd2d185..76e5ca445 100644 --- a/Source/_Tests/AudibleUtilities.Tests/IdentityTokenStorageWiringTests.cs +++ b/Source/_Tests/AudibleUtilities.Tests/IdentityTokenStorageWiringTests.cs @@ -11,6 +11,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Security.Cryptography; namespace IdentityTokenStorageWiringTests; @@ -73,6 +74,9 @@ public void Cleanup() { IdentityTokenStorage.Reset(); Configuration.RestoreSingletonInstance(); + Environment.SetEnvironmentVariable(IdentityTokenStorageWiring.MasterKeyFileEnvVar, null); + Environment.SetEnvironmentVariable(IdentityTokenStorageWiring.MasterKeyEnvVar, null); + Environment.SetEnvironmentVariable(LibationFiles.LIBATION_FILES_DIR, null); if (_tempDir is not null && Directory.Exists(_tempDir)) { @@ -94,6 +98,87 @@ public void Apply_Encrypted_configures_write_method_and_protector_when_store_ava Assert.IsNotNull(IdentityTokenStorage.Protector); } + [TestMethod] + public void ResolveSecretStore_uses_master_key_file_env_before_os_store() + { + var keyPath = WriteTempMasterKeyFile(); + Environment.SetEnvironmentVariable(IdentityTokenStorageWiring.MasterKeyFileEnvVar, keyPath); + Environment.SetEnvironmentVariable(LibationFiles.LIBATION_FILES_DIR, _tempDir); + + var config = Configuration.CreateMockInstance(); + var store = IdentityTokenStorageWiring.ResolveSecretStore(config); + + store.Name.Should().Be("Memory"); + store.TryGet(AesGcmSecretProtector.DefaultMasterKeyName, out var key).Should().BeTrue(); + key.Length.Should().Be(AesGcmSecretProtector.KeySizeBytes); + + IdentityTokenStorageWiring.ConfigureFrom(config); + Assert.IsNotNull(IdentityTokenStorage.Protector); + var payload = IdentityTokenStorage.Protector!.Protect("portable-secret", "aad"); + IdentityTokenStorage.Protector.Unprotect(payload, "aad").Should().Be("portable-secret"); + } + + [TestMethod] + public void ResolveSecretStore_uses_default_libation_master_key_file() + { + Environment.SetEnvironmentVariable(LibationFiles.LIBATION_FILES_DIR, _tempDir); + var config = Configuration.CreateMockInstance(); + var defaultPath = Path.Combine(config.LibationFiles.Location, IdentityTokenStorageWiring.DefaultMasterKeyFileName); + WriteMasterKeyFile(defaultPath); + + var store = IdentityTokenStorageWiring.ResolveSecretStore(config); + store.Name.Should().Be("Memory"); + store.TryGet(AesGcmSecretProtector.DefaultMasterKeyName, out _).Should().BeTrue(); + } + + [TestMethod] + public void ResolveSecretStore_uses_master_key_env_base64() + { + var key = new byte[AesGcmSecretProtector.KeySizeBytes]; + RandomNumberGenerator.Fill(key); + Environment.SetEnvironmentVariable(IdentityTokenStorageWiring.MasterKeyEnvVar, Convert.ToBase64String(key)); + Environment.SetEnvironmentVariable(LibationFiles.LIBATION_FILES_DIR, _tempDir); + + var config = Configuration.CreateMockInstance(); + var store = IdentityTokenStorageWiring.ResolveSecretStore(config); + + store.Name.Should().Be("Memory"); + store.TryGet(AesGcmSecretProtector.DefaultMasterKeyName, out var loaded).Should().BeTrue(); + CollectionAssert.AreEqual(key, loaded); + } + + [TestMethod] + public void ResolveSecretStore_invalid_master_key_env_fails_closed_without_falling_through() + { + Environment.SetEnvironmentVariable(IdentityTokenStorageWiring.MasterKeyEnvVar, "not-valid-base64!!!"); + Environment.SetEnvironmentVariable(LibationFiles.LIBATION_FILES_DIR, _tempDir); + + var config = Configuration.CreateMockInstance(); + var store = IdentityTokenStorageWiring.ResolveSecretStore(config); + + store.IsAvailable.Should().BeFalse(); + store.Name.Should().Be("Portable master key env"); + + config.TokenStorageMethod = TokenStorageMethod.Encrypted; + IdentityTokenStorageWiring.ConfigureFrom(config); + Assert.IsNull(IdentityTokenStorage.Protector); + } + + [TestMethod] + public void ResolveSecretStore_missing_master_key_file_env_fails_closed() + { + var missing = Path.Combine(_tempDir!, "missing-master.key"); + Environment.SetEnvironmentVariable(IdentityTokenStorageWiring.MasterKeyFileEnvVar, missing); + Environment.SetEnvironmentVariable(LibationFiles.LIBATION_FILES_DIR, _tempDir); + + var config = Configuration.CreateMockInstance(); + var store = IdentityTokenStorageWiring.ResolveSecretStore(config); + + store.IsAvailable.Should().BeFalse(); + IdentityTokenStorageWiring.IsEncryptionKeyAvailable(config, out var reason).Should().BeFalse(); + StringAssert.Contains(reason, "unusable"); + } + [TestMethod] public void Apply_Plaintext_configures_plaintext_writes() { @@ -201,4 +286,19 @@ private static Identity CreateRegisteredIdentity() storeAuthenticationCookie: SampleStoreAuthCookie); return identity; } + + private string WriteTempMasterKeyFile() + { + var path = Path.Combine(_tempDir!, "exported-master.key"); + WriteMasterKeyFile(path); + return path; + } + + private static void WriteMasterKeyFile(string path) + { + var key = new byte[AesGcmSecretProtector.KeySizeBytes]; + RandomNumberGenerator.Fill(key); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, key); + } } From 7d9882c500013c959876f9314134b6c9f02a4b47 Mon Sep 17 00:00:00 2001 From: Robert McRackan Date: Sat, 1 Aug 2026 11:43:29 -0400 Subject: [PATCH 007/317] Add LibationCli export-master-key for portable encryption keys --- Source/AudibleUtilities/MasterKeyExport.cs | 39 ++++++++++++++++ .../Options/ExportMasterKeyOptions.cs | 44 +++++++++++++++++++ .../IdentityTokenStorageWiringTests.cs | 27 ++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 Source/AudibleUtilities/MasterKeyExport.cs create mode 100644 Source/LibationCli/Options/ExportMasterKeyOptions.cs diff --git a/Source/AudibleUtilities/MasterKeyExport.cs b/Source/AudibleUtilities/MasterKeyExport.cs new file mode 100644 index 000000000..9a2a6f3ad --- /dev/null +++ b/Source/AudibleUtilities/MasterKeyExport.cs @@ -0,0 +1,39 @@ +using Dinah.Core.Security; + +namespace AudibleUtilities; + +/// +/// Export Libation's OS-bound AES-GCM master key for portable use (e.g. Docker). +/// Never creates a new key; the desktop OS store must already hold one. +/// +public static class MasterKeyExport +{ + /// + /// Write the existing Libation master key as raw bytes to . + /// + /// OS secret store unavailable or master key missing. + public static void ExportToFile(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + + if (!IdentityTokenStorageWiring.IsOsSecretStoreAvailable(out var unavailableReason)) + { + throw new InvalidOperationException( + "Cannot export the encryption master key because the OS secret store is unavailable." + + (string.IsNullOrWhiteSpace(unavailableReason) ? "" : " " + unavailableReason)); + } + + var store = OsSecretStore.Create(IdentityTokenStorageWiring.ApplicationName); + try + { + MasterKeyPortability.ExportToFile(store, filePath); + } + catch (SecretProtectionException ex) + { + throw new InvalidOperationException( + "Cannot export the encryption master key because it was not found. " + + "Encrypt tokens on this machine at least once (Settings -> Important, store tokens encrypted) so a key exists, then try again.", + ex); + } + } +} diff --git a/Source/LibationCli/Options/ExportMasterKeyOptions.cs b/Source/LibationCli/Options/ExportMasterKeyOptions.cs new file mode 100644 index 000000000..86a7e855c --- /dev/null +++ b/Source/LibationCli/Options/ExportMasterKeyOptions.cs @@ -0,0 +1,44 @@ +using AudibleUtilities; +using CommandLine; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace LibationCli; + +[Verb("export-master-key", HelpText = "Export Libation's OS-bound encryption master key to a file for portable use (e.g. Docker). The file unlocks encrypted AccountsSettings.json.")] +internal class ExportMasterKeyOptions : OptionsBase +{ + [Value(0, MetaName = "path", Required = false, HelpText = "Destination path for the raw master key file (e.g. libation-master.key).")] + public string? KeyFilePath { get; set; } + + [Option('p', "path", HelpText = "Destination path for the raw master key file. Alternative to the positional path argument.")] + public string? PathOption { get; set; } + + protected override Task ProcessAsync() + { + var path = (PathOption ?? KeyFilePath)?.Trim(); + if (string.IsNullOrEmpty(path)) + { + PrintVerbUsage("ERROR", "=====", "Path to the master key file is required."); + Environment.ExitCode = (int)ExitCode.RunTimeError; + return Task.CompletedTask; + } + + try + { + MasterKeyExport.ExportToFile(path); + } + catch (Exception ex) + { + PrintVerbUsage("ERROR", "=====", ex.Message); + Environment.ExitCode = (int)ExitCode.RunTimeError; + return Task.CompletedTask; + } + + Console.WriteLine($"Wrote master key to: {Path.GetFullPath(path)}"); + Console.WriteLine("Treat this file like a password: anyone with it can decrypt AccountsSettings.json tokens."); + Console.WriteLine("For Docker, copy it next to AccountsSettings.json as libation-master.key, or set LIBATION_MASTER_KEY_FILE."); + return Task.CompletedTask; + } +} diff --git a/Source/_Tests/AudibleUtilities.Tests/IdentityTokenStorageWiringTests.cs b/Source/_Tests/AudibleUtilities.Tests/IdentityTokenStorageWiringTests.cs index 76e5ca445..7547cab75 100644 --- a/Source/_Tests/AudibleUtilities.Tests/IdentityTokenStorageWiringTests.cs +++ b/Source/_Tests/AudibleUtilities.Tests/IdentityTokenStorageWiringTests.cs @@ -287,6 +287,33 @@ private static Identity CreateRegisteredIdentity() return identity; } + [TestMethod] + public void MasterKeyExport_writes_key_file_when_os_store_has_key() + { + if (!IdentityTokenStorageWiring.IsOsSecretStoreAvailable(out var reason)) + Assert.Inconclusive("OS secret store unavailable: " + reason); + + Environment.SetEnvironmentVariable(LibationFiles.LIBATION_FILES_DIR, _tempDir); + var config = Configuration.CreateMockInstance(); + config.TokenStorageMethod = TokenStorageMethod.Encrypted; + IdentityTokenStorageWiring.ConfigureFrom(config); + + Assert.IsNotNull(IdentityTokenStorage.Protector); + _ = IdentityTokenStorage.Protector!.Protect("seed-master-key"); + + var exportPath = Path.Combine(_tempDir!, "libation-master.key"); + MasterKeyExport.ExportToFile(exportPath); + + File.Exists(exportPath).Should().BeTrue(); + File.ReadAllBytes(exportPath).Length.Should().Be(AesGcmSecretProtector.KeySizeBytes); + + // Portable load of the exported file can decrypt ciphertext from the OS-backed protector. + var payload = IdentityTokenStorage.Protector.Protect("roundtrip-secret", "aad"); + Environment.SetEnvironmentVariable(IdentityTokenStorageWiring.MasterKeyFileEnvVar, exportPath); + IdentityTokenStorageWiring.ConfigureFrom(config); + IdentityTokenStorage.Protector!.Unprotect(payload, "aad").Should().Be("roundtrip-secret"); + } + private string WriteTempMasterKeyFile() { var path = Path.Combine(_tempDir!, "exported-master.key"); From 961ee3372e9fc7085f2cb7a354c9f41c07b51608 Mon Sep 17 00:00:00 2001 From: Robert McRackan Date: Sat, 1 Aug 2026 11:55:38 -0400 Subject: [PATCH 008/317] Harden docker CI against GHA cache write failures --- .github/workflows/docker.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 77eef73b0..ed89291d4 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -59,7 +59,9 @@ jobs: context: . platforms: linux/amd64,linux/arm64 push: ${{ steps.metadata.outputs.tags != ''}} + # Always read cache. Only write on release so PR validate cannot fail + # when GHA cache is full or the token cannot write cache. cache-from: type=gha - cache-to: type=gha,mode=max + cache-to: ${{ inputs.release && 'type=gha,mode=max,ignore-error=true' || '' }} tags: ${{ steps.metadata.outputs.tags }} labels: ${{ steps.metadata.outputs.labels }} From 636b85f8e4638d5f9681e676fd1307ccd2a14966 Mon Sep 17 00:00:00 2001 From: Robert McRackan Date: Sat, 1 Aug 2026 12:09:13 -0400 Subject: [PATCH 009/317] UI: add Settings export for the portable encryption master key --- .../Controls/Settings/Important.axaml | 22 +++++-- .../Controls/Settings/Important.axaml.cs | 29 +++++++++ .../Settings/ImportantSettingsVM.cs | 5 ++ .../LibationUiBase/TokenStorageSettingsUi.cs | 62 +++++++++++++++++++ .../Dialogs/SettingsDialog.Designer.cs | 16 ++++- .../Dialogs/SettingsDialog.Important.cs | 23 +++++++ .../TokenStorageSettingsUiTests.cs | 8 +++ 7 files changed, 158 insertions(+), 7 deletions(-) diff --git a/Source/LibationAvalonia/Controls/Settings/Important.axaml b/Source/LibationAvalonia/Controls/Settings/Important.axaml index 4a968693b..8a67612e7 100644 --- a/Source/LibationAvalonia/Controls/Settings/Important.axaml +++ b/Source/LibationAvalonia/Controls/Settings/Important.axaml @@ -107,14 +107,24 @@ IsVisible="{Binding OsStoreUnavailableVisible}" Text="{Binding OsStoreUnavailableMessage}" TextWrapping="Wrap" /> -