Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions CredentialCache.Test/SerializationEscapingTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests that serialized credentials keep their characters as raw UTF-8 rather than
/// <c>\uXXXX</c> 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.
/// </summary>
[TestClass]
public class SerializationEscapingTests
{
private const string Username = "пользователь";
private const string Password = "a+b<c>d&e'f密码";

[TestMethod]
public void SerializeWritesPlusAndNonAsciiCharactersAsRawUtf8()
{
CredentialWithUsernamePassword credential = new()
{
Username = SemanticString<CredentialUsername>.Create(Username),
Password = SemanticString<CredentialPassword>.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<CredentialToken>.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<CredentialToken>.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<CredentialWithUsernamePassword>(credential);
Assert.AreEqual(Username, loaded.Username.WeakString);
Assert.AreEqual(Password, loaded.Password.WeakString);
}
}
}
1 change: 1 addition & 0 deletions CredentialCache/CredentialCache.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@
<PackageReference Include="ktsu.RoundTripStringJsonConverter" />
<PackageReference Include="Polyfill" PrivateAssets="all" />

<PackageReference Include="System.Text.Encodings.Web" />
<PackageReference Include="System.Text.Json" /></ItemGroup>
</Project>
6 changes: 6 additions & 0 deletions CredentialCache/Storage/CredentialSerialization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -62,6 +63,11 @@ private static JsonSerializerOptions BuildOptions(Dictionary<Type, string> 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<T> as an IEnumerable<char> collection.
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<PackageVersion Include="Microsoft.Testing.Extensions.Retry" Version="1.7.2" />
<PackageVersion Include="Polyfill" Version="11.4.5" />
<PackageVersion Include="ktsu.Semantics.Strings" Version="5.12.1" />
<PackageVersion Include="System.Text.Encodings.Web" Version="10.0.14" />
<PackageVersion Include="System.Text.Json" Version="10.0.14" />
</ItemGroup>
</Project>
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` &mdash; 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` &mdash; 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 &mdash; 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 &mdash; treat `Save` / `Remove` as I/O, not as cheap accessors.
Expand Down
Loading