Erasable Memory and the Dropped string API
The PQC key-format surface on MLKem, MLDsa and SlhDsa follows one rule: no member takes a
password, or returns a plaintext private key, as a string.
A string is immutable. Once a password or a PEM-encoded private key is inside one, nothing can
overwrite it until the garbage collector cleans it up.
This is a BCL convenience rule where we rather enforce secure defaults
The BCL PQC APIs diverged from prior .NET security implementations.
System.Security.Cryptography.AsymmetricAlgorithm — the base that RSA, ECDsa and DSA inherit,
and the most heavily reviewed key-format surface .NET ships — has never had a string
password overload, and it has offered Span<char> PEM exports since they were introduced:
AsymmetricAlgorithm(RSA, ECDsa, DSA) |
in-box MLDsa/MLKem/SlhDsa(.NET 10) |
this library | |
|---|---|---|---|
string password parameters |
none | 5 | none |
ImportFromPem(string) |
no | yes | no |
TryExportPkcs8PrivateKeyPem(Span<char>) |
yes | no | yes |
TryExportSubjectPublicKeyInfoPem(Span<char>) |
yes | no | yes |
TryExportEncryptedPkcs8PrivateKeyPem(…, Span<char>) |
yes | no | yes |
string ExportPkcs8PrivateKeyPem() |
yes | yes | no |
On every row but the last, this library matches AsymmetricAlgorithm and the in-box PQC types do
not. The last row is the one place AsymmetricAlgorithm is also weaker than it could be: it offers
both the allocating plaintext-PEM export and the erasable one, and we offer only the erasable one.
Why the in-box PQC types went off the known track
This is on the record, not inferred.
The original API proposal for MLKem (dotnet/runtime#113508)
contained no string password parameter at all — only ReadOnlySpan<char> and
ReadOnlySpan<byte>, exactly as AsymmetricAlgorithm has always had them. The string overloads
were added later, and the reason is stated in the amendment that finalised them
(dotnet/runtime#115024, API-approved):
We decided that, since the string overloads for password exist purely for ease-of-use for other downlevel platforms, that one should take a byte array instead of a
ReadOnlySpan. There is already an overload that acceptsReadOnlySpan<char>,ReadOnlySpan<byte>, so modern targets still have access to all the span APIs that they want.
So the string overloads are a downlevel-ergonomics affordance: the PQC types ship to .NET
Framework and .NET Standard 2.0 through Microsoft.Bcl.Cryptography, where spans are more awkward to
use, and the string overloads exist to smooth that over. AsymmetricAlgorithm never needed the
same concession because its shape predates that porting effort.
The irony is not lost on us. This library targets those same downlevel platforms — net462
upward — and reached the opposite conclusion: the platforms where spans are least convenient are
also the platforms where an unerasable password is no less dangerous, and convenience is a poor
trade for a secret that cannot be overwritten. The ReadOnlySpan<char> overload works on every one
of those targets; it just costs the caller an .AsSpan() or a char[].
The missing Span<char> PEM exports have a simpler explanation: MLKem, MLDsa and SlhDsa are
not AsymmetricAlgorithm subclasses and could not be — its KeySize, LegalKeySizes,
SignatureAlgorithm and XML key formats mean nothing for a KEM, or for a signature scheme whose
parameter set fixes every size in advance. The key-format block was therefore written fresh rather
than inherited, and the three TryExport*Pem members did not make the copy. The original proposal
does not list them either.
This library is standalone for the same reason, and used that freedom to follow the older shape.
Can a string just be overwritten?
Mechanically yes, and it is a bad idea. MemoryMarshal.AsMemory(s.AsMemory()).Span hands out a
writable view with no unsafe block at all — but a string literal is interned, so two unrelated
variables holding "hunter2" are the same object, and clearing one corrupts every other holder while
leaving Length untouched. The damage is silent until something reads it.
That is only the visible half. The KDF input was encoded from those characters before you could wipe
anything, so a copy has already escaped; wiping the string afterwards achieves nothing. This is
also why SecureString was not the answer and is documented as not recommended — the plaintext has to
be marshalled out to be used regardless.
There is no supported way to erase a string, on any .NET version, and none is planned.
Characters and bytes carry different things
Both password overloads survive, and they are not two spellings of one idea. This is the easiest thing to get wrong here, because getting it wrong does not throw: it silently derives a different key and surfaces much later as "the password is incorrect".
ReadOnlySpan<char> password |
ReadOnlySpan<byte> passwordBytes |
|
|---|---|---|
| What it carries | the password as text | the KDF input as bytes |
| Under PBES2 (what we write) | encoded UTF-8 before use | used verbatim |
| Under PKCS#12 (legacy, read-only) | encoded big-endian UTF-16, NUL-terminated | used verbatim |
| Who chooses the encoding | the scheme in the file | you |
Concretely, for the password hünter:
char[] text = "hünter".ToCharArray();
// The char overload encodes per scheme. Those six characters become seven bytes
// under PBES2 - ü is two bytes in UTF-8 - and fourteen under the PKCS#12 KDF:
// six big-endian UTF-16 units plus a NUL terminator. You never write either
// encoding yourself; the scheme decides, and that is the point of this overload.
key.ExportEncryptedPkcs8PrivateKey(text, pbe);
// The byte overload does no encoding at all. This *is* the KDF input:
ReadOnlySpan<byte> raw = [0x68, 0xC3, 0xBC, 0x6E, 0x74, 0x65, 0x72];
key.ExportEncryptedPkcs8PrivateKey(raw, pbe);
Under PBES2 — the only scheme this library writes — the character encoding is UTF-8, so a u8
literal and the equivalent characters derive the same key. That holds for any text, not just ASCII:
"hünter"u8 and "hünter".ToCharArray() open each other's files, and so do emoji. Which is exactly
why the distinction is easy to miss.
They diverge in two cases, both real:
- Reading a legacy PKCS#12 file. Characters become big-endian UTF-16 with a NUL terminator, so
"hunter"u8will not open a file that"hunter".ToCharArray()wrote — six bytes against fourteen. - Byte inputs that are not valid UTF-8.
[0x00, 0xFF, 0x10]is not the UTF-8 encoding of any string, so no character password reaches it. That is what the byte overload is for: reproducing a derivation input this library would not otherwise produce.
EncryptedKeyFormatTests.ByteAndCharacterPasswordsAreNotInterchangeable and
ErasableMemoryTests.Utf8LiteralAndCharacterPasswordsAgreeUnderPbes2 pin both halves.
Use the char overload for anything a human typed. Use the byte overload when the derivation input
is what you actually hold.
What was dropped, and what to use instead
Each row is present on all three key types, so the counts are per type × 3.
Passwords as string — 15 members
| Dropped | Use instead |
|---|---|
ImportEncryptedPkcs8PrivateKey(string password, byte[] source) |
ImportEncryptedPkcs8PrivateKey(ReadOnlySpan<char>, ReadOnlySpan<byte>) |
ExportEncryptedPkcs8PrivateKey(string password, PbeOptions) |
ExportEncryptedPkcs8PrivateKey(ReadOnlySpan<char>, PbeOptions) |
ExportEncryptedPkcs8PrivateKeyPem(string password, PbeOptions) |
ExportEncryptedPkcs8PrivateKeyPem(ReadOnlySpan<char>, PbeOptions) |
TryExportEncryptedPkcs8PrivateKey(string password, PbeOptions, Span<byte>, out int) |
TryExportEncryptedPkcs8PrivateKey(ReadOnlySpan<char>, PbeOptions, Span<byte>, out int) |
ImportFromEncryptedPem(string source, string password) |
ImportFromEncryptedPem(ReadOnlySpan<char>, ReadOnlySpan<char>) |
Plaintext private keys as string — 6 members
| Dropped | Use instead |
|---|---|
string ExportPkcs8PrivateKeyPem() |
GetPkcs8PrivateKeyPemSize() + TryExportPkcs8PrivateKeyPem(Span<char>, out int) |
ImportFromPem(string source) |
ImportFromPem(ReadOnlySpan<char>) |
ExportPkcs8PrivateKeyPem is the one member with no signature-compatible replacement, because there
is no way to return a string and also let the caller erase it.
What still returns a string
Two members, both because their content is public by construction:
ExportSubjectPublicKeyInfoPem()— a public key.ExportEncryptedPkcs8PrivateKeyPem(...)— ciphertext. Protecting it is the point of the method.
This is enforced, not merely stated: ErasableMemoryTests reflects over the three types on every
target framework and fails if any other public member takes a string password or returns a
string.
Sizing the buffer
TryExportPkcs8PrivateKeyPem needs a buffer, and guessing is not viable — an ML-DSA-87 expanded-key
PEM runs past 6,700 characters, well beyond the 4,096 most people would reach for. So each type also
carries a size member:
int size = key.GetPkcs8PrivateKeyPemSize(); // exact, not an upper bound
int size = key.GetSubjectPublicKeyInfoPemSize();
The BCL offers neither. Its own TryExportPkcs8PrivateKeyPem leaves you to grow a buffer until it
succeeds, or to reach for PemEncoding.GetEncodedSize(labelLength, dataLength) — which needs the DER
length, which you only get by exporting the key first, defeating the purpose. Both size members here
build the encoding to measure it and clear it immediately, costing one extra encode.
There is deliberately no size member for the encrypted PEM export. Measuring it means running
PBKDF2, and at a realistic iteration count that is the entire cost of the export — a caller who sized
and then exported would pay it twice. Use the allocating ExportEncryptedPkcs8PrivateKeyPem, which
is safe precisely because the payload is ciphertext.
Migrating
A password moves from a literal to storage you control:
// before
byte[] encrypted = key.ExportEncryptedPkcs8PrivateKey("hunter2", pbe);
// after
char[] password = ReadPasswordFromTheUser(); // or stackalloc char[n], or a rented array
try
{
byte[] encrypted = key.ExportEncryptedPkcs8PrivateKey(password, pbe);
}
finally
{
CryptographicOperations.ZeroMemory(password);
}
Use CryptographicOperations.ZeroMemory rather than Array.Clear or Span<T>.Clear().
Clearing a buffer you are about to discard is a dead store - nothing reads the zeros back - so a
compiler or JIT is free to delete the write and leave the secret in memory. ZeroMemory is marked
NoInlining | NoOptimization, which removes that liberty. This library exposes its own because the
in-box type does not exist below .NET Standard 2.1 and, on every version that has it, still takes
only a Span<byte>: there is no character overload in the box on any .NET version.
If a file imports both CryptoHives.Foundation.Security.Cryptography and
System.Security.Cryptography, the two type names collide (CS0104) - alias one side, e.g.
using Bcl = System.Security.Cryptography;.
When the password genuinely is a constant
ReadOnlySpan<char> still accepts "literal".AsSpan(), and nothing stops you passing one — what
changed is that the API no longer forces it. If the input really is fixed (a test vector, a key
derivation input you do not control), a UTF-8 literal is the better spelling:
ReadOnlySpan<byte> password = "hunter2"u8;
It never creates a String, which "hunter2".ToCharArray() does. Two things it is not: it is still
compiled into the assembly, so it is no more erasable than a literal was; and per the table above it
binds the byte overload, so it is the KDF input verbatim rather than text to be encoded.
The plaintext PEM export
int size = key.GetPkcs8PrivateKeyPemSize();
char[] pem = ArrayPool<char>.Shared.Rent(size);
try
{
if (!key.TryExportPkcs8PrivateKeyPem(pem, out int charsWritten))
{
throw new InvalidOperationException("unreachable: the buffer was sized by the key");
}
File.WriteAllText(path, new string(pem, 0, charsWritten)); // or write the span directly
}
finally
{
CryptographicOperations.ZeroMemory(pem.AsSpan(0, size));
ArrayPool<char>.Shared.Return(pem);
}
When the buffer is too small nothing is written and charsWritten is zero, matching the TryExport
contract used everywhere else on these types.
Inside the library
The same rule was applied to the implementation, not only the surface:
PemFormatworks entirely in spans. The reader returns index ranges into the caller's document rather thanSubstringresults, soImportFromPem(ReadOnlySpan<char>)no longer materializes the document as a string on the way in. The writer emits into the caller's buffer instead of building aStringBuilder.- The base64 scratch buffer — the one intermediate that cannot be designed away, since base64 needs somewhere to land before it is laid out into 64-character lines — is rented and zeroed.
Pkcs8.ReadandPqcPrivateKeyChoice.Readmust copy their input, becauseAsnReadertakes aReadOnlyMemory<byte>and a span cannot supply one. Both copies are now cleared in afinally.PbePasswordno longer callsToArray()on the password characters on the downlevel targets.
One residual
AsnWriter keeps an internal copy of everything written to it and offers no way to clear it. Its
Reset() is documented as resetting the writer without releasing resources, and the IDisposable
in System.Formats.Asn1 is AsnWriter.Scope, the push/pop helper, not the writer itself. So
Pkcs8.Write and the ML-KEM/ML-DSA private-key CHOICE writer leave a plaintext copy of the key in a
buffer this library cannot reach.
Closing it would mean hand-writing the DER for those two structures — a SEQUENCE, an INTEGER, an OID and an OCTET STRING each. That is deliberately not done here: "no hand-rolled DER" was a founding constraint of this layer, and these are exactly the two structures a reader would most want produced by a tested encoder. The exposure is a heap buffer that is never handed out and is overwritten by the next use of the pooled array. It is recorded here rather than silently accepted.
Reviewing the removed implementations
The dropped members were not deleted. Each sits under #if OBSOLETE_SECRET_AS_STRING_API, with a
comment above the fence — visible whether or not the symbol is defined — naming why it went, so the
unerasable implementation can be diffed against the one that replaced it:
// Dropped from the shipping surface: a string password cannot be overwritten once created.
// Retained unbuilt for review; see docfx/packages/security/cryptography/erasable-memory.md.
#if OBSOLETE_SECRET_AS_STRING_API
public static MLDsa ImportEncryptedPkcs8PrivateKey(string password, byte[] source)
#endif
No shipping build defines that symbol; opting in is explicit:
dotnet build src/Security/Cryptography/Cryptography.csproj -p:EnableObsoleteSecretAsStringApi=true
Nothing built that way should be shipped or packed.
Roadmap
ReadOnlySpan<char> is where password overloads land today, and the platform has no better answer to
offer. SecureString is documented as not recommended for new development and does not encrypt at
all outside Windows; System.Text.Utf8String was prototyped for .NET Core 3.0 and never shipped — the
type does not exist in .NET 10. What .NET settled on is not a holder type at all, but this same pair
of spans, with the caller owning both the storage and the erasure.
A dedicated UTF-8 string type remains the intended destination here — it would make the encoding explicit rather than implied by the overload chosen, and give a password a type of its own — and will arrive as an additional overload alongside the spans, not as a replacement for them.
See Also
- Signature Algorithms — the key-format surface on
MLDsaandSlhDsa - KEM Algorithms — the same surface on
MLKem