Scrub Linux Secret Service plaintext like Windows and macOS - #162
Conversation
The Linux store routed every credential through a managed string. TryLoad read the secret with Marshal.PtrToStringUTF8 and deserialized it with the non-scrubbing DeserializeFromString; Save built the plaintext JSON as a string and let the marshaller make a native copy it frees unscrubbed. A managed string is immutable, so neither copy could be zeroed - the plaintext sat on the GC heap, movable by compaction and readable from a crash dump, for an unbounded time after use. Windows got this treatment in #144 and macOS already had it; Linux was left behind. Both paths now use byte arrays and unmanaged buffers this code owns and zeroes, matching the other two stores. secret_password_store_sync takes the password as an IntPtr rather than a string, so the runtime cannot make an unscrubbed copy of its own. NativeSecretBuffer gains the two primitives that needs, because libsecret deals in nul-terminated C strings where the other platforms pass a pointer and a length: NulTerminatedCopyOf, which appends the terminator and covers it in Length so Zero scrubs the whole allocation, and ReadNulTerminated, which copies up to the terminator into an array the caller scrubs. Also pins all three stores to the scrubbing helpers by inspecting which one each is compiled against. That is what was missing: the primitives were tested, but nothing checked that a store called them, which is how one platform stayed on the string path through two releases. Fixes #160 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLBd6CLjwb1r6wuDqDzW4J
SonarCloud's quality gate failed on #162: 70.4% coverage on new code against a required 80%. Measured locally with cobertura, all 20 uncovered new lines were in LinuxSecretServiceCredentialStore's own body, which cannot execute without a Secret Service, while the new NativeSecretBuffer primitives were fully covered. Two causes, both addressed. Most of those 20 lines were the pre-existing secret_password_store_sync call, which the try/finally wrapper re-indented and so marked as new code. Save now takes a using declaration instead, so the call block is untouched except for the one argument that had to change. The rest was composition logic sitting in a method body no test can reach. NativeSecretBuffer.OfCredential and ReadCredential now own serialize-copy-scrub and read-deserialize-scrub, so the store's two methods are one call each and the logic is exercised by tests on every platform - the principle SecretScrubbingTests already states, applied to the part that was still in the store. New uncovered lines in the store drop from 20 to 3, and new-code line coverage measured locally rises from 58.7% to 90.6%. The remaining three are the store's own two statements and one call argument, which need libsecret to run. The wiring guard accepts either the direct helper or the new wrapper, and a third case pins the wrappers themselves to the scrubbing helpers, so the indirection it now allows cannot hide a string-based path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLBd6CLjwb1r6wuDqDzW4J
Pushed
|
| before | after | |
|---|---|---|
| uncovered new lines in the store | 20 | 3 |
| new-code line coverage (local, Linux only) | 58.7% | 90.6% |
The three that remain are the store's two statements and one call argument. They need libsecret; there is no honest way to cover them from here, and I have not tried to hide them.
The guard still catches the original defect
Accepting the wrapper as a stand-in for a direct call would weaken the wiring test, so a third case pins the wrappers themselves:
Assert.IsTrue(Calls(ReadCredential, DeserializeAndScrub), ...);
Assert.IsFalse(Calls(ReadCredential, DeserializeFromString), ...);Re-proved against main's store (git checkout origin/main -- LinuxSecretServiceCredentialStore.cs), two consecutive runs:
failed EveryNativeStoreLoadsThroughTheScrubbingDeserializer (6ms)
LinuxSecretServiceCredentialStore.TryLoad must deserialize through DeserializeAndScrub,
directly or via NativeSecretBuffer.ReadCredential, so the plaintext copy it read is zeroed.
failed EveryNativeStoreSavesFromScrubbableBytes (0ms)
LinuxSecretServiceCredentialStore.Save must serialize to bytes it can zero, directly or via
NativeSecretBuffer.OfCredential.
total: 51 failed: 2 succeeded: 44 skipped: 5
Still naming the Linux store only — Windows and macOS pass, which is the scan's positive control firing correctly.
Worth recording, because it nearly fooled me: once this branch had a commit, git checkout <path> restores from the index, not from main. My first re-proof attempt "reverted" the file to the already-fixed committed version and the tests passed, which looked like the guard had gone blind. It had not — the revert was the thing that was wrong. Reverting from origin/main explicitly is what the run above does.
Verification
dotnet build CredentialCache.sln -c Release— 0 warnings, 0 errors,net9.0andnet10.0dotnet test CredentialCache.sln -c Release— 51 total, 46 passed, 0 failed, 5 skipped, three consecutive runs- Against
main's store — 2 of 51 failed, two consecutive runs, as above
4 tests added for the two new wrappers, including that ReadCredential answers null for all three "nothing to read" cases (no entry, empty entry, unparseable bytes), since a store reads that as "no credential for this persona" rather than an error.
Generated by Claude Code
#161 and this branch both added a bullet to README.md's platform notes at the same point, so merging #161 left this branch conflicted. Both bullets are kept: the persona-locking one from #161 first, since it continues the thread-safety bullet above it, then this branch's plaintext-scrubbing one. No code conflicted. The two changes are independent - #161 locks the cache and store per persona in CredentialCache.cs, this one moves the Linux store's plaintext onto scrubbed buffers - and the Save path's new NativeSecretBuffer.OfCredential call runs under that lock without re-entering the cache, so the two compose. 54 tests pass on the merge result. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLBd6CLjwb1r6wuDqDzW4J
|



Fixes #160
The defect
LinuxSecretServiceCredentialStorerouted every credential through a managedstring:TryLoadread the secret withMarshal.PtrToStringUTF8and deserialized it withDeserializeFromString— the path that does not scrub.Savebuilt the plaintext JSON withSerializeToStringand handed thestringtosecret_password_store_sync, so the marshaller also made a native copy it frees without scrubbing.A managed string is immutable, so neither copy can be zeroed. The plaintext sits on the GC heap — movable by compaction, readable from a crash dump or a heap dump — for an unbounded time after use. That is exactly the exposure
CredentialSerialization's own doc comment gives as the reasonDeserializeAndScrubandNativeSecretBufferexist.The change
Both paths now use byte arrays and unmanaged buffers this code owns and zeroes, which is what Windows and macOS already do:
secret_password_store_sync'spasswordparameter is nowIntPtr, notstring. That is the part doing the work. Serializing to bytes but still declaring the parameter as a string would have left the runtime free to marshal its own native copy and free it unscrubbed — the property has to hold at the P/Invoke boundary, not just in the caller.NativeSecretBuffergains the two primitives this needs, because libsecret deals in nul-terminated C strings where Windows and macOS pass a pointer and a length:NulTerminatedCopyOf(byte[])LengthsoZeroscrubs the whole allocation rather than leaving the last byte. It also zeroes its own managed intermediate on every path out, including whenCopyOfthrows.ReadNulTerminated(IntPtr)DeserializeAndScrub. This is what lets a C-string store take the same byte-array path as a pointer-and-length one.NulTerminatedCopyOfrejects a source carrying its own nul byte rather than silently truncating. Nothing can produce one today —SerializeToUtf8Bytesescapes control characters — but a C-string API given an embedded nul would store a truncated secret, which fails silently at save time and surfaces much later as an unparseable blob.The issue's fallback — document the gap if libsecret offers no byte entry point — turned out not to be needed.
secret_password_lookup_binary_sync(its other suggestion) would mean three more P/Invokes and aSecretValuelifetime to manage; the returnedgchar*is nul-terminated, and JSON never contains an interior nul, so scanning for the terminator gets there with one small helper instead.The test that was actually missing
SecretScrubbingTestsopens by explaining that scrubbing "lives in these two shared primitives rather than being re-implemented (and left untested) in each store", because a native store is only reachable on its own OS. That is the right design, and it had a hole: the primitives were tested, but nothing checked that a store called them. Windows was moved onto them in #144, Linux was not, and no test noticed for two releases.NativeStoreScrubbingWiringTestscloses that. For each of the three native stores it assertsTryLoadis compiled againstDeserializeAndScruband notDeserializeFromString, andSaveagainstSerializeand notSerializeToString.Whether a secret passed through a managed string is not observable at runtime — the plaintext's whole problem is that it lingers somewhere nothing can reach — so this inspects the IL for the call instead. Both helpers live in the same assembly as the stores, so a call site carries the target's own MethodDef token and matching on it needs no opcode table.
Two things keep that honest:
Confirmed in practice: against the unfixed store the two tests fail naming the Linux store only — Windows and macOS pass, which is the control firing correctly.
Also added to
SecretScrubbingTests, in its existing idiom: 7 cases over the two new primitives, covering the terminator being appended and scrubbed, the embedded-nul rejection, the empty-source and null-pointer edges, reading past a terminator, and the read-then-scrub composition a store performs.Proved failing without the fix. Reverting only
LinuxSecretServiceCredentialStore.cs— the new primitives stay, so it still compiles — two consecutive runs:Worth being straight about the split: the wiring tests are what fail on the defect. The 7 primitive tests cover new methods, so they cannot fail against
main— they would not compile there. They are coverage for the new code, not evidence for the fix.Verification
dotnet build CredentialCache.sln -c Release— succeeded, 0 warnings, 0 errors, acrossnet9.0andnet10.0dotnet test CredentialCache.sln -c Release— 46 total, 41 passed, 0 failed, 5 skipped, two consecutive runsLinuxSecretServiceCredentialStore.cs— 2 of 46 failed, two consecutive runs, as aboveThe 5 skips are pre-existing and environmental: 4
NativeCredentialStoreTestscases self-skip because this container has no Secret Service (secret_schema_newfails at theSchemastatic initializer), and 1 is Windows-only.The changed native path is therefore not executed anywhere in this run. It is exercised by
NativeCredentialStoreTestson thecross-platform.ymlleg that brings updbus-run-session+gnome-keyring-daemon, and that leg is where a marshalling mistake in the newIntPtrsignature would show up. Flagging it rather than implying the suite covers it.Docs
README.md's platform notes gain a bullet stating that all three native stores handle plaintext on the same terms, and that a credential the caller holds afterTryGetis an ordinary managed object whose lifetime is theirs — the library scrubs its own copies, not yours.The Linux store's class doc gains the same statement; the issue notes it previously read as if scrubbing were platform-uniform when it was not.
Worth knowing
Building this repository rewrites
.gitignorein the working tree from the SDK's shared template, adding Unity and Godot rules unrelated to anything here. It is not part of this diff; I reverted it. Anyone working this repository will see it reappear after a build.🤖 Generated with Claude Code
https://claude.ai/code/session_01LLBd6CLjwb1r6wuDqDzW4J
Generated by Claude Code