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.