What's wrong
CredentialSerialization.Serialize (CredentialCache/Storage/CredentialSerialization.cs:34) is JsonSerializer.SerializeToUtf8Bytes(credential, Options). All three native stores call it on Save:
- Windows:
WindowsCredentialStore.cs:79
- macOS:
MacOsCredentialStore.cs:77
- Linux, via
NativeSecretBuffer.OfCredential: NativeSecretBuffer.cs:152
Each store then zeroes the byte[] it gets back (the work of #144, #160 and #165).
That buffer isn't the only copy, though. Internally, SerializeToUtf8Bytes writes into a buffer rented from ArrayPool<byte>.Shared, which is 16 KB for a credential-sized payload. It copies the result out with ToArray() and returns the rented buffer without clearing it. So a second, identical plaintext copy of every saved token or password stays in the process-wide shared pool:
- Any unrelated code that rents a 16 KB array gets that array back, with the secret still in it.
- The secret shows up in crash dumps for as long as the process lives.
This contradicts the README's guarantee (README.md:156): "every copy the library owns is a byte array or an unmanaged buffer that is overwritten with zeros before it is released".
Reproduction
This was verified on .NET 10.0.12 with System.Text.Json 10.0.12, the version the repo pins:
var cred = new CredentialWithToken { Token = "SUPERSECRET-" + Guid.NewGuid() };
byte[] bytes = CredentialSerialization.Serialize(cred);
Array.Clear(bytes); // what the stores do
byte[] rented = ArrayPool<byte>.Shared.Rent(16384);
// rented contains the UTF-8 "SUPERSECRET-..." JSON
Of the power-of-two sizes, only Rent(16384) returned the secret; every other size was clean. The deserialize path (Deserialize(byte[])) leaves nothing behind in the pool.
Suggested fix
- Serialize with
JsonSerializer.Serialize(Utf8JsonWriter, credential, Options) into a private IBufferWriter<byte> that zeroes its old array each time it grows, and zeroes its final array on dispose.
- Hand the stores the written span, or copy it out and zero the original.
- A prototype of this wrapper (a small scrubbing buffer writer around
new Utf8JsonWriter(writer)) produced byte-identical output, and no pool size contained the secret afterwards.
Acceptance criteria
- After a Save on any store, renting
ArrayPool<byte>.Shared arrays of every power-of-two size up to 64 KB finds no trace of the secret.
- A test in the style of
SecretScrubbingTests covers this. It needs no native keyring, so it runs on every OS.
What's wrong
CredentialSerialization.Serialize(CredentialCache/Storage/CredentialSerialization.cs:34) isJsonSerializer.SerializeToUtf8Bytes(credential, Options). All three native stores call it on Save:WindowsCredentialStore.cs:79MacOsCredentialStore.cs:77NativeSecretBuffer.OfCredential:NativeSecretBuffer.cs:152Each store then zeroes the
byte[]it gets back (the work of #144, #160 and #165).That buffer isn't the only copy, though. Internally,
SerializeToUtf8Byteswrites into a buffer rented fromArrayPool<byte>.Shared, which is 16 KB for a credential-sized payload. It copies the result out withToArray()and returns the rented buffer without clearing it. So a second, identical plaintext copy of every saved token or password stays in the process-wide shared pool:This contradicts the README's guarantee (README.md:156): "every copy the library owns is a byte array or an unmanaged buffer that is overwritten with zeros before it is released".
Reproduction
This was verified on .NET 10.0.12 with System.Text.Json 10.0.12, the version the repo pins:
Of the power-of-two sizes, only
Rent(16384)returned the secret; every other size was clean. The deserialize path (Deserialize(byte[])) leaves nothing behind in the pool.Suggested fix
JsonSerializer.Serialize(Utf8JsonWriter, credential, Options)into a privateIBufferWriter<byte>that zeroes its old array each time it grows, and zeroes its final array on dispose.new Utf8JsonWriter(writer)) produced byte-identical output, and no pool size contained the secret afterwards.Acceptance criteria
ArrayPool<byte>.Sharedarrays of every power-of-two size up to 64 KB finds no trace of the secret.SecretScrubbingTestscovers this. It needs no native keyring, so it runs on every OS.