diff --git a/CredentialCache.Test/NativeStoreScrubbingWiringTests.cs b/CredentialCache.Test/NativeStoreScrubbingWiringTests.cs
new file mode 100644
index 0000000..2f2409e
--- /dev/null
+++ b/CredentialCache.Test/NativeStoreScrubbingWiringTests.cs
@@ -0,0 +1,160 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.CredentialCache.Test;
+
+using System.Reflection;
+using ktsu.CredentialCache.Storage;
+
+///
+/// Pins every platform-native store to the scrubbing helpers rather than the
+/// string-based ones.
+///
+///
+///
+/// proves the scrubbing primitives work. Nothing
+/// proved that each store actually calls them, and that gap is what this class closes:
+/// the Windows store was moved onto them in #144 and the Linux store was left behind
+/// until #160, because a store can only be exercised on its own operating system and
+/// two of the three are therefore unreachable from any single CI leg.
+///
+///
+/// Whether a secret was routed through an immutable managed is not
+/// observable at runtime — the plaintext's problem is precisely that it lingers where
+/// nothing can see or reach it — so this inspects which helper each store is compiled
+/// against instead. The "must call" assertions double as a check on the IL scan itself:
+/// a scan that found nothing would fail them rather than quietly passing the "must not
+/// call" half.
+///
+///
+[TestClass]
+public class NativeStoreScrubbingWiringTests
+{
+ private static readonly MethodInfo DeserializeAndScrub = Helper(nameof(CredentialSerialization.DeserializeAndScrub));
+ private static readonly MethodInfo DeserializeFromString = Helper(nameof(CredentialSerialization.DeserializeFromString));
+ private static readonly MethodInfo Serialize = Helper(nameof(CredentialSerialization.Serialize));
+ private static readonly MethodInfo SerializeToString = Helper(nameof(CredentialSerialization.SerializeToString));
+
+ private static readonly MethodInfo ReadCredential = Buffer(nameof(NativeSecretBuffer.ReadCredential));
+ private static readonly MethodInfo OfCredential = Buffer(nameof(NativeSecretBuffer.OfCredential));
+
+ private static MethodInfo Helper(string name) =>
+ typeof(CredentialSerialization).GetMethod(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)
+ ?? throw new InvalidOperationException($"{nameof(CredentialSerialization)}.{name} not found.");
+
+ private static MethodInfo Buffer(string name) =>
+ typeof(NativeSecretBuffer).GetMethod(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)
+ ?? throw new InvalidOperationException($"{nameof(NativeSecretBuffer)}.{name} not found.");
+
+ private static IEnumerable NativeStores() =>
+ [
+ typeof(WindowsCredentialStore),
+ typeof(MacOsCredentialStore),
+ typeof(LinuxSecretServiceCredentialStore),
+ ];
+
+ private static MethodInfo Method(Type store, string name) =>
+ store.GetMethod(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
+ ?? throw new InvalidOperationException($"{store.Name}.{name} not found.");
+
+ ///
+ /// Reports whether 's body contains a call or
+ /// callvirt to .
+ ///
+ ///
+ /// Both live in this assembly, so the call site carries the target's own MethodDef
+ /// token and matching on it needs no opcode table. A byte sequence that happened to
+ /// look like such a call without being one could only make an assertion stricter,
+ /// never let a violation through.
+ ///
+ private static bool Calls(MethodInfo method, MethodInfo target)
+ {
+ byte[] il = method.GetMethodBody()?.GetILAsByteArray()
+ ?? throw new InvalidOperationException($"No IL available for {method.DeclaringType?.Name}.{method.Name}.");
+ Assert.AreEqual(
+ method.Module,
+ target.Module,
+ "The scan matches a MethodDef token, so both methods must live in one module.");
+
+ byte[] token = BitConverter.GetBytes(target.MetadataToken);
+
+ for (int i = 0; i + 5 <= il.Length; i++)
+ {
+ if (il[i] is not (0x28 or 0x6F))
+ {
+ continue;
+ }
+
+ if (il[i + 1] == token[0] && il[i + 2] == token[1] && il[i + 3] == token[2] && il[i + 4] == token[3])
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ [TestMethod]
+ public void EveryNativeStoreLoadsThroughTheScrubbingDeserializer()
+ {
+ foreach (Type store in NativeStores())
+ {
+ MethodInfo tryLoad = Method(store, nameof(ICredentialStore.TryLoad));
+
+ // Either directly, or through NativeSecretBuffer.ReadCredential, which the test
+ // below pins to the same helper. A store whose native API hands back a C string
+ // reads through that wrapper so the length-finding is covered by tests rather
+ // than sitting in a method body that only runs on one operating system.
+ Assert.IsTrue(
+ Calls(tryLoad, DeserializeAndScrub) || Calls(tryLoad, ReadCredential),
+ $"{store.Name}.TryLoad must deserialize through DeserializeAndScrub, directly or via "
+ + "NativeSecretBuffer.ReadCredential, so the plaintext copy it read is zeroed.");
+ Assert.IsFalse(
+ Calls(tryLoad, DeserializeFromString),
+ $"{store.Name}.TryLoad must not deserialize from a string: an immutable managed string "
+ + "holds the plaintext until the GC happens to collect it, and cannot be scrubbed.");
+ }
+ }
+
+ [TestMethod]
+ public void EveryNativeStoreSavesFromScrubbableBytes()
+ {
+ foreach (Type store in NativeStores())
+ {
+ MethodInfo save = Method(store, nameof(ICredentialStore.Save));
+
+ Assert.IsTrue(
+ Calls(save, Serialize) || Calls(save, OfCredential),
+ $"{store.Name}.Save must serialize to bytes it can zero, directly or via "
+ + "NativeSecretBuffer.OfCredential.");
+ Assert.IsFalse(
+ Calls(save, SerializeToString),
+ $"{store.Name}.Save must not serialize to a string: the plaintext would sit on the managed "
+ + "heap unscrubbable, and marshalling it would add a native copy freed without scrubbing.");
+ }
+ }
+
+ ///
+ /// The second hop of the two assertions above. Accepting the wrapper as equivalent to a
+ /// direct call is only sound while the wrapper itself uses the scrubbing helper, so that is
+ /// asserted rather than assumed.
+ ///
+ [TestMethod]
+ public void TheSharedCredentialWrappersUseTheScrubbingHelpers()
+ {
+ Assert.IsTrue(
+ Calls(ReadCredential, DeserializeAndScrub),
+ "NativeSecretBuffer.ReadCredential must deserialize through DeserializeAndScrub; the store "
+ + "assertions above accept it as a stand-in for calling that helper directly.");
+ Assert.IsFalse(
+ Calls(ReadCredential, DeserializeFromString),
+ "NativeSecretBuffer.ReadCredential must not deserialize from a string.");
+
+ Assert.IsTrue(
+ Calls(OfCredential, Serialize),
+ "NativeSecretBuffer.OfCredential must serialize to bytes it can zero; the store assertions "
+ + "above accept it as a stand-in for calling that helper directly.");
+ Assert.IsFalse(
+ Calls(OfCredential, SerializeToString),
+ "NativeSecretBuffer.OfCredential must not serialize to a string.");
+ }
+}
diff --git a/CredentialCache.Test/SecretScrubbingTests.cs b/CredentialCache.Test/SecretScrubbingTests.cs
index a9ecb01..ea3da40 100644
--- a/CredentialCache.Test/SecretScrubbingTests.cs
+++ b/CredentialCache.Test/SecretScrubbingTests.cs
@@ -158,6 +158,136 @@ public void NativeSecretBufferHandlesAnEmptySource()
public void NativeSecretBufferRejectsANullSource() =>
Assert.ThrowsExactly(() => NativeSecretBuffer.CopyOf(null!));
+ [TestMethod]
+ public void NulTerminatedCopyOfAppendsTheTerminator()
+ {
+ byte[] blob = SerializedCredential();
+
+ using NativeSecretBuffer buffer = NativeSecretBuffer.NulTerminatedCopyOf(blob);
+
+ Assert.AreEqual(blob.Length + 1, buffer.Length, "The terminator has to be part of the allocation.");
+ Assert.AreSequenceEqual([.. blob, (byte)0], ReadUnmanaged(buffer));
+ }
+
+ [TestMethod]
+ public void NulTerminatedCopyOfZeroesTheWholeAllocationIncludingTheTerminator()
+ {
+ byte[] blob = SerializedCredential();
+ using NativeSecretBuffer buffer = NativeSecretBuffer.NulTerminatedCopyOf(blob);
+
+ buffer.Zero();
+
+ // Length covers the terminator, so a scrub that used the payload length would
+ // leave the last byte alone. Read back before Dispose, as above.
+ Assert.AreSequenceEqual(new byte[blob.Length + 1], ReadUnmanaged(buffer));
+ }
+
+ [TestMethod]
+ public void NulTerminatedCopyOfRejectsASourceCarryingItsOwnNul()
+ {
+ // A native C-string API would read a truncated secret and store it, which fails
+ // silently at save time and surfaces as an unparseable blob on the next load.
+ byte[] blob = [.. "{\"x\":1}"u8, 0, .. "tail"u8];
+
+ ArgumentException thrown = Assert.ThrowsExactly(
+ () => NativeSecretBuffer.NulTerminatedCopyOf(blob));
+
+ Assert.AreEqual("source", thrown.ParamName);
+ }
+
+ [TestMethod]
+ public void NulTerminatedCopyOfHandlesAnEmptySource()
+ {
+ using NativeSecretBuffer buffer = NativeSecretBuffer.NulTerminatedCopyOf([]);
+
+ Assert.AreEqual(1, buffer.Length);
+ Assert.AreSequenceEqual(new byte[] { 0 }, ReadUnmanaged(buffer));
+ }
+
+ [TestMethod]
+ public void ReadNulTerminatedCopiesUpToTheTerminatorOnly()
+ {
+ byte[] blob = SerializedCredential();
+ // Trailing bytes past the terminator stand in for whatever else the native
+ // allocation happens to hold; none of it is part of the secret.
+ using NativeSecretBuffer stored = NativeSecretBuffer.CopyOf([.. blob, 0, .. "trailing"u8]);
+
+ byte[] read = NativeSecretBuffer.ReadNulTerminated(stored.Pointer);
+
+ Assert.AreSequenceEqual(blob, read);
+ }
+
+ [TestMethod]
+ public void ReadNulTerminatedRoundTripsACredentialWithoutAManagedString()
+ {
+ // The exact composition a C-string store performs: read the bytes, then scrub
+ // the managed copy. Nothing in between is a string.
+ using NativeSecretBuffer stored = NativeSecretBuffer.NulTerminatedCopyOf(SerializedCredential());
+
+ byte[] read = NativeSecretBuffer.ReadNulTerminated(stored.Pointer);
+ Credential? credential = CredentialSerialization.DeserializeAndScrub(read);
+
+ CredentialWithToken? typed = credential as CredentialWithToken;
+ Assert.IsNotNull(typed);
+ Assert.AreEqual("plaintext-token-to-scrub", typed!.Token.ToString());
+ Assert.AreSequenceEqual(new byte[read.Length], read, "The managed copy must be zeroed once deserialized.");
+ }
+
+ [TestMethod]
+ public void ReadNulTerminatedReturnsEmptyForAnEmptyStringAndForNull()
+ {
+ using NativeSecretBuffer empty = NativeSecretBuffer.CopyOf([0]);
+
+ Assert.IsEmpty(NativeSecretBuffer.ReadNulTerminated(empty.Pointer));
+ Assert.IsEmpty(NativeSecretBuffer.ReadNulTerminated(IntPtr.Zero));
+ }
+
+ [TestMethod]
+ public void OfCredentialProducesTheSerializedCredentialNulTerminated()
+ {
+ using NativeSecretBuffer buffer = NativeSecretBuffer.OfCredential(new CredentialWithToken
+ {
+ Token = SemanticString.Create("plaintext-token-to-scrub"),
+ });
+
+ // Byte-for-byte what a C-string native API should receive: the same JSON the other two
+ // stores hand over as a pointer and a length, plus the terminator.
+ Assert.AreSequenceEqual([.. SerializedCredential(), (byte)0], ReadUnmanaged(buffer));
+ }
+
+ [TestMethod]
+ public void OfCredentialAndReadCredentialRoundTripACredential()
+ {
+ using NativeSecretBuffer buffer = NativeSecretBuffer.OfCredential(new CredentialWithToken
+ {
+ Token = SemanticString.Create("plaintext-token-to-scrub"),
+ });
+
+ Credential? credential = NativeSecretBuffer.ReadCredential(buffer.Pointer);
+
+ CredentialWithToken? typed = credential as CredentialWithToken;
+ Assert.IsNotNull(typed);
+ Assert.AreEqual("plaintext-token-to-scrub", typed!.Token.ToString());
+ }
+
+ [TestMethod]
+ public void OfCredentialRejectsANullCredential() =>
+ Assert.ThrowsExactly(() => NativeSecretBuffer.OfCredential(null!));
+
+ [TestMethod]
+ public void ReadCredentialReturnsNullWhereThereIsNoCredentialToRead()
+ {
+ using NativeSecretBuffer empty = NativeSecretBuffer.CopyOf([0]);
+ using NativeSecretBuffer garbage = NativeSecretBuffer.NulTerminatedCopyOf([.. "{ not a credential"u8]);
+
+ // A store treats null as "nothing stored for this persona", so all three of these have to
+ // answer null rather than throwing: no entry, an empty entry, and an entry that is not
+ // parseable as a credential.
+ Assert.IsNull(NativeSecretBuffer.ReadCredential(IntPtr.Zero));
+ Assert.IsNull(NativeSecretBuffer.ReadCredential(empty.Pointer));
+ Assert.IsNull(NativeSecretBuffer.ReadCredential(garbage.Pointer));
+ }
+
[TestMethod]
public void ZeroOverwritesTheBuffer()
{
diff --git a/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs b/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs
index 3b3076c..6e1b930 100644
--- a/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs
+++ b/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs
@@ -12,10 +12,19 @@ namespace ktsu.CredentialCache.Storage;
/// service and account identifying it.
///
///
+///
/// Requires libsecret to be installed on the host. On headless systems without
/// a running secret-service implementation (e.g. minimal containers, build agents)
/// this provider will fail at the first operation; consumers should detect this
/// and fall back to if appropriate.
+///
+///
+/// Plaintext credential bytes are scrubbed on the same terms as the Windows and macOS
+/// stores: every copy this code owns is a array or a
+/// that is zeroed on the way out, and no plaintext is
+/// ever marshalled through a managed . libsecret scrubs its own
+/// copy in secret_password_free.
+///
///
[SupportedOSPlatform("linux")]
internal sealed class LinuxSecretServiceCredentialStore : ICredentialStore
@@ -57,16 +66,16 @@ public bool TryLoad(PersonaGUID persona, out Credential? credential)
try
{
- string? value = Marshal.PtrToStringUTF8(passwordPtr);
- if (string.IsNullOrEmpty(value))
- {
- return false;
- }
- credential = CredentialSerialization.DeserializeFromString(value);
+ // Read as bytes and scrubbed, never through Marshal.PtrToStringUTF8: an immutable
+ // string cannot be scrubbed, so the plaintext would outlive the call. Same
+ // byte-array-and-scrub path the Windows and macOS stores take; libsecret just hands
+ // back a C string rather than a pointer and a length.
+ credential = NativeSecretBuffer.ReadCredential(passwordPtr);
return credential is not null;
}
finally
{
+ // secret_password_free wipes libsecret's own copy before releasing it.
NativeMethods.secret_password_free(passwordPtr);
}
}
@@ -77,7 +86,10 @@ public void Save(PersonaGUID persona, Credential credential)
ArgumentNullException.ThrowIfNull(persona);
ArgumentNullException.ThrowIfNull(credential);
- string value = CredentialSerialization.SerializeToString(credential);
+ // An unmanaged nul-terminated copy this code owns and zeroes, not a managed string:
+ // marshalling a string would put the plaintext on the managed heap where it cannot be
+ // scrubbed, and leave the runtime's own native copy to be freed unscrubbed.
+ using NativeSecretBuffer value = NativeSecretBuffer.OfCredential(credential);
string label = $"{_serviceName}:{persona}";
IntPtr error = IntPtr.Zero;
@@ -85,7 +97,7 @@ public void Save(PersonaGUID persona, Credential credential)
Schema.Handle,
IntPtr.Zero,
label,
- value,
+ value.Pointer,
IntPtr.Zero,
ref error,
"service", _serviceName,
@@ -175,12 +187,15 @@ internal static extern IntPtr secret_password_lookup_sync(
[DllImport(Lib, CharSet = CharSet.Ansi)]
internal static extern void secret_password_free(IntPtr password);
+ // password is an IntPtr, not a string, so the plaintext is never marshalled by the
+ // runtime into a copy it frees without scrubbing. The caller owns a
+ // NativeSecretBuffer for it.
[DllImport(Lib, CharSet = CharSet.Ansi)]
internal static extern bool secret_password_store_sync(
IntPtr schema,
IntPtr collection,
string label,
- string password,
+ IntPtr password,
IntPtr cancellable,
ref IntPtr error,
string attribute1Name, string attribute1Value,
diff --git a/CredentialCache/Storage/NativeSecretBuffer.cs b/CredentialCache/Storage/NativeSecretBuffer.cs
index 289f880..746c4d7 100644
--- a/CredentialCache/Storage/NativeSecretBuffer.cs
+++ b/CredentialCache/Storage/NativeSecretBuffer.cs
@@ -60,6 +60,132 @@ internal static NativeSecretBuffer CopyOf(byte[] source)
return new NativeSecretBuffer(pointer, source.Length);
}
+ ///
+ /// Copies into newly allocated unmanaged memory followed by a
+ /// single nul byte, for a native API that takes a C string rather than a pointer and a
+ /// length.
+ ///
+ /// The plaintext bytes to copy. Must contain no nul byte of its own.
+ /// A buffer owning the unmanaged copy, which the caller must dispose.
+ ///
+ /// covers the terminator, so scrubs the whole
+ /// allocation. Marshalling a managed string would be the obvious alternative and is the
+ /// thing to avoid: the runtime frees the native copy it makes without scrubbing it, and
+ /// the immutable managed string it came from cannot be scrubbed at all.
+ ///
+ internal static NativeSecretBuffer NulTerminatedCopyOf(byte[] source)
+ {
+ ArgumentNullException.ThrowIfNull(source);
+
+ if (Array.IndexOf(source, (byte)0) >= 0)
+ {
+ throw new ArgumentException(
+ "A nul-terminated copy cannot carry a nul byte of its own; the native call would read a truncated secret.",
+ nameof(source));
+ }
+
+ byte[] terminated = new byte[source.Length + 1];
+ try
+ {
+ Buffer.BlockCopy(source, 0, terminated, 0, source.Length);
+ return CopyOf(terminated);
+ }
+ finally
+ {
+ // terminated is a second plaintext copy on the managed heap and has served its
+ // purpose by here, whether CopyOf succeeded or threw.
+ CredentialSerialization.Zero(terminated);
+ }
+ }
+
+ ///
+ /// Serializes and copies it into unmanaged memory as a
+ /// nul-terminated UTF-8 JSON string, for a native API that takes a C string.
+ ///
+ /// The credential to persist.
+ /// A buffer owning the unmanaged copy, which the caller must dispose.
+ ///
+ /// Both managed copies made along the way — the serialized bytes and the nul-terminated
+ /// array — are zeroed before this returns, on the throwing path as well. Owning the whole
+ /// sequence here rather than in each store is what keeps it exercised by tests: a store's
+ /// own body only runs on its own operating system.
+ ///
+ internal static NativeSecretBuffer OfCredential(Credential credential)
+ {
+ ArgumentNullException.ThrowIfNull(credential);
+
+ byte[] blob = CredentialSerialization.Serialize(credential);
+ try
+ {
+ return NulTerminatedCopyOf(blob);
+ }
+ finally
+ {
+ CredentialSerialization.Zero(blob);
+ }
+ }
+
+ ///
+ /// Reads the nul-terminated UTF-8 JSON at and deserializes it,
+ /// scrubbing the managed copy it read.
+ ///
+ /// A pointer to a nul-terminated plaintext credential blob.
+ ///
+ /// The credential, or when addresses
+ /// nothing, an empty string, or bytes that are not a known credential.
+ ///
+ ///
+ /// The counterpart of , and the read path a store whose native API
+ /// returns a C string should use. Nothing here is ever a managed : one
+ /// would hold the plaintext until the GC happened to collect it and could not be scrubbed
+ /// at all.
+ ///
+ internal static Credential? ReadCredential(IntPtr pointer)
+ {
+ byte[] blob = ReadNulTerminated(pointer);
+ return blob.Length == 0 ? null : CredentialSerialization.DeserializeAndScrub(blob);
+ }
+
+ ///
+ /// Copies the nul-terminated bytes at into a managed array,
+ /// excluding the terminator.
+ ///
+ /// A pointer to nul-terminated plaintext in unmanaged memory.
+ ///
+ /// The bytes before the first nul, or an empty array when is
+ /// or addresses an empty string.
+ ///
+ ///
+ /// The caller owns the returned array and is expected to hand it to
+ /// , which scrubs it.
+ /// This exists so a store whose native API returns a C string can take the same
+ /// byte-array-and-scrub path as one that returns a pointer and a length, instead of
+ /// going through and stranding the
+ /// plaintext in an immutable managed string.
+ ///
+ internal static byte[] ReadNulTerminated(IntPtr pointer)
+ {
+ if (pointer == IntPtr.Zero)
+ {
+ return [];
+ }
+
+ int length = 0;
+ while (Marshal.ReadByte(pointer, length) != 0)
+ {
+ length++;
+ }
+
+ if (length == 0)
+ {
+ return [];
+ }
+
+ byte[] copy = new byte[length];
+ Marshal.Copy(pointer, copy, 0, length);
+ return copy;
+ }
+
///
/// Overwrites the unmanaged copy with zeros while the memory is still allocated.
/// Idempotent, and a no-op once the buffer has been disposed.
diff --git a/README.md b/README.md
index 6f68f91..f6fa590 100644
--- a/README.md
+++ b/README.md
@@ -153,6 +153,7 @@ else
- **Linux** requires `libsecret-1` plus an active Secret Service. Headless CI agents typically have neither — 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 — treat `Save` / `Remove` as I/O, not as cheap accessors.
- `AddOrReplace`, `Remove`, and the store-loading half of `TryGet` each update the cache *and* the store, so they hold a lock on the persona for the duration rather than relying on the dictionary alone. Concurrent calls for the same persona therefore run one at a time and the two always end up agreeing; concurrent calls for different personas are unaffected. Because that lock is held across a store call, a `Remove` and an `AddOrReplace` racing on one persona serialize at the speed of the native store — another reason to treat them as I/O.
+- All three native stores handle plaintext on the same terms: every copy the library owns is a byte array or an unmanaged buffer that is overwritten with zeros before it is released, and no secret is ever routed through a managed `string`, which is immutable and so cannot be scrubbed at all. A credential you hold yourself is a different matter — anything you read out of `TryGet` is an ordinary object on the managed heap and its lifetime is yours.
## API summary