fix: scrub plaintext credential buffers before releasing them [patch] - #149
Merged
Merged
Conversation
WindowsCredentialStore left two plaintext copies of a credential behind: TryLoad never cleared the managed byte[] it deserialized from, and Save handed its Marshal.AllocHGlobal copy to Marshal.FreeHGlobal without zeroing it first, so the plaintext stayed in the process heap until that allocation happened to be reused. Both now route through shared primitives that the tests can observe on any platform: CredentialSerialization.DeserializeAndScrub zeroes the managed copy on both the success and the failure path, and the new NativeSecretBuffer zeroes an unmanaged copy before freeing it. MacOsCredentialStore was audited at the same time. It already cleared its buffers, but TryLoad skipped the clear if deserialization threw; it now uses the same two helpers. Zeroing goes through CryptographicOperations.ZeroMemory (managed) and Marshal.Copy of a zero-filled array (unmanaged) rather than Array.Clear, so the runtime cannot elide it as a dead store. Fixes #144 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ENSG6H3g5YSd1mQMPD8EGd
MSTEST0068 (8x): use Assert.AreSequenceEqual / AreNotSequenceEqual rather than the legacy CollectionAssert equivalents. No other test file in the repo used CollectionAssert, so this was new drift. S2699 (blocker): ZeroToleratesEmptyAndNullBuffers had no assertion. Its subject is that Zero's guard clauses return instead of throwing, so it now names that in a comment and asserts the empty buffer it passed in. Re-verified the revert proof under the new assertion API: stubbing out both scrubs still fails the same 5 tests, so the converted asserts still compare contents rather than references. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ENSG6H3g5YSd1mQMPD8EGd
|
This was referenced Sep 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Fixes #144
The problem
WindowsCredentialStoreleft two plaintext copies of a credential behind:TryLoadcopied the stored secret into a managedbyte[], deserialized it, and returned — never clearing the array. It lingered on the managed heap, subject to GC promotion and compaction, until eventually collected, where it could still be read out of a crash dump.MacOsCredentialStorealready did the right thing here.Savecleared its managedblobbut handed theMarshal.AllocHGlobalcopy — the identical plaintext bytes passed toCredWriteW— straight toMarshal.FreeHGlobal. Freed unmanaged memory is not scrubbed by the CLR or the OS heap, so the plaintext stayed in the process heap until that allocation happened to be reused.The change
Both paths now go through shared primitives rather than each store re-implementing (and each store's copy going untested):
CredentialSerialization.DeserializeAndScrub(byte[])— deserializes, then zeroes the managed copy in afinally, so the plaintext is scrubbed on the failure path too. A blob that fails to parse still came out of the platform store, so it is still secret-bearing.NativeSecretBuffer(new, internal) — owns an unmanaged copy and zeroes it beforeFreeHGlobal.Zero()is separate fromDispose()so the scrub is observable while the allocation is still live;Dispose()isZero()then free.CredentialSerialization.Zero(byte[])— the one place managed scrubbing happens.Zeroing goes through
CryptographicOperations.ZeroMemory(managed) andMarshal.Copyof a zero-filled array (unmanaged) rather thanArray.Clear, per the triage note, so the runtime cannot discard it as a dead store. The unmanaged path avoidsunsafe/SpanbecauseAllowUnsafeBlocksis off in this project;Marshal.Copyinto unmanaged memory is an opaque interop call and is not elidable either, and its source array is freshly zeroed so it holds no secret of its own.Audit of the other platform stores
The triage asked for this while in here.
MacOsCredentialStore— already cleared both buffers, butTryLoadskipped itsArray.Clearif deserialization threw. Now uses the same two helpers, so the failure path is covered and the clears can't drift apart again.LinuxSecretServiceCredentialStore— not changed here. It never holds a plaintextbyte[]: it round-trips throughSerializeToString/DeserializeFromStringand hands libsecret astring. A .NETstringis immutable and interned-adjacent, so it cannot be scrubbed at all — fixing that means moving that store off the string-based API, which is a separate design change rather than a missingZero()call. Worth its own issue; flagging rather than widening this PR.Testing
New
CredentialCache.Test/SecretScrubbingTests.cs— 13 tests, running on every platform. The native stores are only reachable on their own OS, which is exactly why the scrubbing lives in these two shared primitives: they are testable where the stores are not.The key one,
NativeSecretBufferZeroOverwritesThePlaintextWhileStillAllocated, reads the unmanaged bytes back beforeDispose— reading freed memory would be undefined, so the scrub has to be observable while the allocation is live. That is the exact orderingDisposerelies on.Proven to actually catch the bug: with
Zero()and theDeserializeAndScrubscrub temporarily stubbed out, 5 of the new tests fail (Expected: 0 / Actual: 123— the leading{of the credential JSON still sitting in the buffer), covering both the managed-copy and unmanaged-buffer paths. With the fix restored they pass.Full suite, both configurations:
total: 37, failed: 0, succeeded: 32, skipped: 5total: 37, failed: 0, succeeded: 32, skipped: 5The 5 skips are the pre-existing
NativeCredentialStoreTests, which reportInconclusivewithout a Secret Service daemon — unchanged from the baseline onmain(19 passing → 19 passing, +13 new). Release matters here specifically because dead-store elision is a Release-JIT concern, and the scrub assertions hold there.No public API change; both new helpers are
internal(the test project already hasInternalsVisibleTo).🤖 Generated with Claude Code
https://claude.ai/code/session_01ENSG6H3g5YSd1mQMPD8EGd
Generated by Claude Code