You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Saving a credential leaves its plaintext JSON in ArrayPool<byte>.Shared: zeroing the byte[] from SerializeToUtf8Bytes misses System.Text.Json's pooled copy #185
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:
varcred=newCredentialWithToken{Token="SUPERSECRET-"+Guid.NewGuid()};byte[]bytes=CredentialSerialization.Serialize(cred);Array.Clear(bytes);// what the stores dobyte[]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.
Priority: High. Every Save leaves the plaintext secret in ArrayPool<byte>.Shared, where unrelated code that rents a 16 KB array can read it, and it turns up in crash dumps. This breaks the README's zeroing guarantee (README.md:156).
Area:CredentialCache/Storage/CredentialSerialization.cs. All three native stores share this path, so one fix covers Windows, macOS and Linux.
Notes: the proposed fix is a scrubbing IBufferWriter<byte> plus Utf8JsonWriter. It is already prototyped with byte-identical output, and its test needs no native keyring, so it runs on every OS. Small and well specified.
Activity
matt-edmondson commentedon Oct 6, 2026
Triage
ArrayPool<byte>.Shared, where unrelated code that rents a 16 KB array can read it, and it turns up in crash dumps. This breaks the README's zeroing guarantee (README.md:156).CredentialCache/Storage/CredentialSerialization.cs. All three native stores share this path, so one fix covers Windows, macOS and Linux.IBufferWriter<byte>plusUtf8JsonWriter. It is already prototyped with byte-identical output, and its test needs no native keyring, so it runs on every OS. Small and well specified.Generated by Claude Code