From 1a320c59df9d83adf97674f485625da583d9bcfb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 05:24:44 +0000 Subject: [PATCH] Write credential JSON with relaxed escaping so Windows' blob cap counts real bytes System.Text.Json's default encoder wrote '+', '<', '>', '&', '\'' and every non-ASCII character as a six-byte \uXXXX escape, so a 2400-character base64 token serialized to 2662 bytes and Windows Credential Manager rejected it against its 2560-byte cap. Serialize with UnsafeRelaxedJsonEscaping instead; the payload is never embedded in HTML or script, and blobs written with the old escapes still deserialize. Fixes ktsu-dev/CredentialCache#175 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW3kaZKRNvfNtvM4CX6 --- .../SerializationEscapingTests.cs | 83 +++++++++++++++++++ CredentialCache/CredentialCache.csproj | 1 + .../Storage/CredentialSerialization.cs | 6 ++ Directory.Packages.props | 1 + README.md | 2 +- 5 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 CredentialCache.Test/SerializationEscapingTests.cs diff --git a/CredentialCache.Test/SerializationEscapingTests.cs b/CredentialCache.Test/SerializationEscapingTests.cs new file mode 100644 index 0000000..8df7c50 --- /dev/null +++ b/CredentialCache.Test/SerializationEscapingTests.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CredentialCache.Test; + +using System.Text; +using ktsu.CredentialCache.Storage; +using ktsu.Semantics.Strings; + +/// +/// Tests that serialized credentials keep their characters as raw UTF-8 rather than +/// \uXXXX escapes, which would grow each one to six bytes and push secrets over +/// size-capped stores such as Windows Credential Manager's 2560-byte blob. +/// +[TestClass] +public class SerializationEscapingTests +{ + private const string Username = "пользователь"; + private const string Password = "a+bd&e'f密码"; + + [TestMethod] + public void SerializeWritesPlusAndNonAsciiCharactersAsRawUtf8() + { + CredentialWithUsernamePassword credential = new() + { + Username = SemanticString.Create(Username), + Password = SemanticString.Create(Password), + }; + + string json = Encoding.UTF8.GetString(CredentialSerialization.Serialize(credential)); + + Assert.DoesNotContain("\\u", json); + Assert.Contains(Username, json); + Assert.Contains(Password, json); + } + + [TestMethod] + public void SerializeToStringWritesPlusAndNonAsciiCharactersAsRawUtf8() + { + CredentialWithToken credential = new() + { + Token = SemanticString.Create(Password), + }; + + string json = CredentialSerialization.SerializeToString(credential); + + Assert.DoesNotContain("\\u", json); + Assert.Contains(Password, json); + } + + [TestMethod] + public void SerializedBase64TokenIsNoLargerThanItsCharactersPlusTheEnvelope() + { + string token = Convert.ToBase64String([.. Enumerable.Range(0, 1800).Select(i => (byte)(i * 251))]); + Assert.Contains("+", token); + CredentialWithToken credential = new() + { + Token = SemanticString.Create(token), + }; + + byte[] blob = CredentialSerialization.Serialize(credential); + + Assert.IsLessThan(token.Length + 64, blob.Length); + } + + [TestMethod] + public void PreviouslyEscapedBlobStillDeserializes() + { + const string escaped = + "{\"$type\":\"CredentialWithUsernamePassword\"," + + "\"Username\":\"\\u043F\\u043E\\u043B\\u044C\\u0437\\u043E\\u0432\\u0430\\u0442\\u0435\\u043B\\u044C\"," + + "\"Password\":\"a\\u002Bb\\u003Cc\\u003Ed\\u0026e\\u0027f\\u5BC6\\u7801\"}"; + + Credential? fromBytes = CredentialSerialization.Deserialize(Encoding.UTF8.GetBytes(escaped)); + Credential? fromString = CredentialSerialization.DeserializeFromString(escaped); + + foreach (Credential? credential in new[] { fromBytes, fromString }) + { + CredentialWithUsernamePassword loaded = Assert.IsInstanceOfType(credential); + Assert.AreEqual(Username, loaded.Username.WeakString); + Assert.AreEqual(Password, loaded.Password.WeakString); + } + } +} diff --git a/CredentialCache/CredentialCache.csproj b/CredentialCache/CredentialCache.csproj index 280637e..b59554f 100644 --- a/CredentialCache/CredentialCache.csproj +++ b/CredentialCache/CredentialCache.csproj @@ -20,5 +20,6 @@ + diff --git a/CredentialCache/Storage/CredentialSerialization.cs b/CredentialCache/Storage/CredentialSerialization.cs index 0faf895..0f3ccc0 100644 --- a/CredentialCache/Storage/CredentialSerialization.cs +++ b/CredentialCache/Storage/CredentialSerialization.cs @@ -4,6 +4,7 @@ namespace ktsu.CredentialCache.Storage; using System.Reflection; using System.Security.Cryptography; +using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; @@ -62,6 +63,11 @@ private static JsonSerializerOptions BuildOptions(Dictionary deriv { WriteIndented = false, TypeInfoResolver = resolver, + // The default encoder escapes '+', '<', '>', '&', '\'' and every non-ASCII character + // as a six-byte \uXXXX, which pushes secrets over size-capped stores such as Windows + // Credential Manager's 2560-byte blob. The payload is never embedded in HTML or + // script, so HTML-safe escaping buys nothing, and the reader accepts both forms. + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; // Persuade System.Text.Json to use the SemanticString factory methods (Create / FromString) // rather than treating SemanticString as an IEnumerable collection. diff --git a/Directory.Packages.props b/Directory.Packages.props index 1332f61..f3a4d7b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,7 @@ + diff --git a/README.md b/README.md index 0138425..393bb31 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ else ## Platform notes -- **Windows Credential Manager** caps the credential blob at 2560 bytes (`5 * 512`). Tokens larger than that throw `CredentialStoreException` — split or compress before storing. +- **Windows Credential Manager** caps the credential blob at 2560 bytes (`5 * 512`). The cap counts the serialized credential, which is UTF-8 JSON, so a non-ASCII character takes two to four bytes of it. Credentials larger than that throw `CredentialStoreException` — split or compress before storing. - **macOS** uses the user's default login keychain. The first access from an application prompts the user for permission, as with any keychain client. - **Linux** requires `libsecret-1` plus an active Secret Service. Headless CI agents typically have neither — use `InMemoryCredentialStore` there, or set up `dbus-run-session` + `gnome-keyring-daemon` as the `cross-platform.yml` workflow does. - All native calls happen on the thread the API is invoked from. The library's in-memory cache is thread-safe (`ConcurrentDictionary`); the native APIs themselves are documented as thread-safe by their respective platform owners, but blocking calls (especially libsecret) are not cheap — treat `Save` / `Remove` as I/O, not as cheap accessors.