Skip to content

ML-KEM: ImportFrom{Encrypted}Pem #114155

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Apr 4, 2025
200 changes: 200 additions & 0 deletions src/libraries/Common/src/System/Security/Cryptography/MLKem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,206 @@ public static MLKem ImportEncryptedPkcs8PrivateKey(ReadOnlySpan<char> password,
out _);
}

/// <summary>
/// Imports an ML-KEM key from an RFC 7468 PEM-encoded string.
/// </summary>
/// <param name="source">
/// The text of the PEM key to import.
/// </param>
/// <returns>
/// The imported ML-KEM key.
/// </returns>
/// <exception cref="ArgumentException">
/// <para><paramref name="source" /> contains an encrypted PEM-encoded key.</para>
/// <para>-or-</para>
/// <para><paramref name="source" /> contains multiple PEM-encoded ML-KEM keys.</para>
/// <para>-or-</para>
/// <para><paramref name="source" /> contains no PEM-encoded ML-KEM keys.</para>
/// </exception>
/// <exception cref="CryptographicException">
/// An error occurred while importing the key.
/// </exception>
/// <remarks>
/// <para>
/// Unsupported or malformed PEM-encoded objects will be ignored. If multiple supported PEM labels
/// are found, an exception is raised to prevent importing a key when the key is ambiguous.
/// </para>
/// <para>
/// This method supports the following PEM labels:
/// <list type="bullet">
/// <item><description>PUBLIC KEY</description></item>
/// <item><description>PRIVATE KEY</description></item>
/// </list>
/// </para>
/// </remarks>
public static MLKem ImportFromPem(ReadOnlySpan<char> source)
{
ThrowIfNotSupported();

return PemKeyHelpers.ImportFactoryPem<MLKem>(source, label =>
label switch
{
PemLabels.Pkcs8PrivateKey => ImportPkcs8PrivateKey,
PemLabels.SpkiPublicKey => ImportSubjectPublicKeyInfo,
_ => null,
});
}

/// <inheritdoc cref="ImportFromPem(ReadOnlySpan{char})" />
/// <exception cref="ArgumentNullException">
/// <paramref name="source" /> is <see langword="null" />
/// </exception>
public static MLKem ImportFromPem(string source)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was not in the initial API design, and we don't have it on other types like RSA. However, I think it is useful for .NET Framework. In .NET Framework, there is no implicit conversion from string to ReadOnlySpan<char>. So even C# developers will be forced to use the AsSpan extension method, unless we give them a nice overload to use.

{
ThrowIfNull(source);
return ImportFromPem(source.AsSpan());
}

/// <summary>
/// Imports an ML-KEM key from an encrypted RFC 7468 PEM-encoded string.
/// </summary>
/// <param name="source">
/// The PEM text of the encrypted key to import.</param>
/// <param name="password">
/// The password to use for decrypting the key material.
/// </param>
/// <exception cref="ArgumentException">
/// <para>
/// <paramref name="source"/> does not contain a PEM-encoded key with a recognized label.
/// </para>
/// <para>-or-</para>
/// <para>
/// <paramref name="source"/> contains multiple PEM-encoded keys with a recognized label.
/// </para>
/// </exception>
/// <exception cref="CryptographicException">
/// <para>
/// The password is incorrect.
/// </para>
/// <para>-or-</para>
/// <para>
/// The base-64 decoded contents of the PEM text from <paramref name="source" />
/// do not represent an ASN.1-BER-encoded PKCS#8 EncryptedPrivateKeyInfo structure.
/// </para>
/// <para>-or-</para>
/// <para>
/// The base-64 decoded contents of the PEM text from <paramref name="source" />
/// indicate the key is for an algorithm other than the algorithm
/// represented by this instance.
/// </para>
/// <para>-or-</para>
/// <para>
/// The base-64 decoded contents of the PEM text from <paramref name="source" />
/// represent the key in a format that is not supported.
/// </para>
/// <para>-or-</para>
/// <para>
/// An error occurred while importing the key.
/// </para>
/// </exception>
/// <remarks>
/// <para>
/// When the base-64 decoded contents of <paramref name="source" /> indicate an algorithm that uses PBKDF1
/// (Password-Based Key Derivation Function 1) or PBKDF2 (Password-Based Key Derivation Function 2),
/// the password is converted to bytes via the UTF-8 encoding.
/// </para>
/// <para>
/// Unsupported or malformed PEM-encoded objects will be ignored. If multiple supported PEM labels
/// are found, an exception is thrown to prevent importing a key when
/// the key is ambiguous.
/// </para>
/// <para>This method supports the <c>ENCRYPTED PRIVATE KEY</c> PEM label.</para>
/// </remarks>
public static MLKem ImportFromEncryptedPem(ReadOnlySpan<char> source, ReadOnlySpan<char> password)
{
return PemKeyHelpers.ImportEncryptedFactoryPem<MLKem, char>(
source,
password,
ImportEncryptedPkcs8PrivateKey);
}

/// <summary>
/// Imports an ML-KEM key from an encrypted RFC 7468 PEM-encoded string.
/// </summary>
/// <param name="source">
/// The PEM text of the encrypted key to import.</param>
/// <param name="passwordBytes">
/// The password to use for decrypting the key material.
/// </param>
/// <exception cref="ArgumentException">
/// <para>
/// <paramref name="source"/> does not contain a PEM-encoded key with a recognized label.
/// </para>
/// <para>-or-</para>
/// <para>
/// <paramref name="source"/> contains multiple PEM-encoded keys with a recognized label.
/// </para>
/// </exception>
/// <exception cref="CryptographicException">
/// <para>
/// The password is incorrect.
/// </para>
/// <para>-or-</para>
/// <para>
/// The base-64 decoded contents of the PEM text from <paramref name="source" />
/// do not represent an ASN.1-BER-encoded PKCS#8 EncryptedPrivateKeyInfo structure.
/// </para>
/// <para>-or-</para>
/// <para>
/// The base-64 decoded contents of the PEM text from <paramref name="source" />
/// indicate the key is for an algorithm other than the algorithm
/// represented by this instance.
/// </para>
/// <para>-or-</para>
/// <para>
/// The base-64 decoded contents of the PEM text from <paramref name="source" />
/// represent the key in a format that is not supported.
/// </para>
/// <para>-or-</para>
/// <para>
/// An error occurred while importing the key.
/// </para>
/// </exception>
/// <remarks>
/// <para>
/// Unsupported or malformed PEM-encoded objects will be ignored. If multiple supported PEM labels
/// are found, an exception is thrown to prevent importing a key when
/// the key is ambiguous.
/// </para>
/// <para>This method supports the <c>ENCRYPTED PRIVATE KEY</c> PEM label.</para>
/// </remarks>
public static MLKem ImportFromEncryptedPem(ReadOnlySpan<char> source, ReadOnlySpan<byte> passwordBytes)
{
return PemKeyHelpers.ImportEncryptedFactoryPem<MLKem, byte>(
source,
passwordBytes,
ImportEncryptedPkcs8PrivateKey);
}

/// <inheritdoc cref="ImportFromEncryptedPem(ReadOnlySpan{char}, ReadOnlySpan{char})" />
/// <exception cref="ArgumentNullException">
/// <paramref name="source" /> or <paramref name="password" /> is <see langword="null" />
/// </exception>
public static MLKem ImportFromEncryptedPem(string source, string password)
{
ThrowIfNull(source);
ThrowIfNull(password);

return ImportFromEncryptedPem(source.AsSpan(), password.AsSpan());
}

/// <inheritdoc cref="ImportFromEncryptedPem(ReadOnlySpan{char}, ReadOnlySpan{byte})" />
/// <exception cref="ArgumentNullException">
/// <paramref name="source" /> or <paramref name="passwordBytes" /> is <see langword="null" />
/// </exception>
public static MLKem ImportFromEncryptedPem(string source, byte[] passwordBytes)
{
ThrowIfNull(source);
ThrowIfNull(passwordBytes);

return ImportFromEncryptedPem(source.AsSpan(), new ReadOnlySpan<byte>(passwordBytes));
}

/// <summary>
/// Releases all resources used by the <see cref="MLKem"/> class.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Diagnostics;
using System.Security.Cryptography;

namespace System.Security.Cryptography
{
internal static partial class PemKeyHelpers
{
internal delegate TAlg ImportFactoryKeyAction<TAlg>(ReadOnlySpan<byte> source);
internal delegate ImportFactoryKeyAction<TAlg>? FindImportFactoryActionFunc<TAlg>(ReadOnlySpan<char> label);
internal delegate TAlg ImportFactoryEncryptedKeyAction<TAlg, TPass>(ReadOnlySpan<TPass> password, ReadOnlySpan<byte> source);

internal static TAlg ImportFactoryPem<TAlg>(ReadOnlySpan<char> source, FindImportFactoryActionFunc<TAlg> callback) where TAlg : class
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is largely based on the already existing ImportPem from PemKeyHelpers. We need a new import method because the new PQC types have a different API shape than the ones on AsymmetricAlgorithm (they are static, and they don't out the number of bytes read).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried de-duping the logic between the two but it got real messy because the delegates are different types and we need type parameters in this one.

{
ImportFactoryKeyAction<TAlg>? importAction = null;
PemFields foundFields = default;
ReadOnlySpan<char> foundSlice = default;
bool containsEncryptedPem = false;

ReadOnlySpan<char> pem = source;
while (PemEncoding.TryFind(pem, out PemFields fields))
{
ReadOnlySpan<char> label = pem[fields.Label];
ImportFactoryKeyAction<TAlg>? action = callback(label);

if (action is not null)
{
if (importAction is not null || containsEncryptedPem)
{
throw new ArgumentException(SR.Argument_PemImport_AmbiguousPem, nameof(source));
}

importAction = action;
foundFields = fields;
foundSlice = pem;
}
else if (label.SequenceEqual(PemLabels.EncryptedPkcs8PrivateKey))
{
if (importAction != null || containsEncryptedPem)
{
throw new ArgumentException(SR.Argument_PemImport_AmbiguousPem, nameof(source));
}

containsEncryptedPem = true;
}

Index offset = fields.Location.End;
pem = pem[offset..];
}

// The only PEM found that could potentially be used is encrypted PKCS8,
// but we won't try to import it with a null or blank password, so
// throw.
if (containsEncryptedPem)
{
throw new ArgumentException(SR.Argument_PemImport_EncryptedPem, nameof(source));
}

// We went through the PEM and found nothing that could be handled.
if (importAction is null)
{
throw new ArgumentException(SR.Argument_PemImport_NoPemFound, nameof(source));
}

ReadOnlySpan<char> base64Contents = foundSlice[foundFields.Base64Data];
#if NET
int base64size = foundFields.DecodedDataLength;
byte[] decodeBuffer = CryptoPool.Rent(base64size);
int bytesWritten = 0;

try
{
if (!Convert.TryFromBase64Chars(base64Contents, decodeBuffer, out bytesWritten))
{
// Couldn't decode base64. We shouldn't get here since the
// contents are pre-validated.
Debug.Fail("Base64 decoding failed on already validated contents.");
throw new ArgumentException();
}

Debug.Assert(bytesWritten == base64size);
Span<byte> decodedBase64 = decodeBuffer.AsSpan(0, bytesWritten);

return importAction(decodedBase64);
}
finally
{
CryptoPool.Return(decodeBuffer, clearSize: bytesWritten);
}
#else
return importAction(Convert.FromBase64String(base64Contents.ToString()));
#endif
}

internal static TAlg ImportEncryptedFactoryPem<TAlg, TPass>(
ReadOnlySpan<char> source,
ReadOnlySpan<TPass> password,
ImportFactoryEncryptedKeyAction<TAlg, TPass> importAction) where TAlg : class
{
bool foundEncryptedPem = false;
PemFields foundFields = default;
ReadOnlySpan<char> foundSlice = default;

ReadOnlySpan<char> pem = source;
while (PemEncoding.TryFind(pem, out PemFields fields))
{
ReadOnlySpan<char> label = pem[fields.Label];

if (label.SequenceEqual(PemLabels.EncryptedPkcs8PrivateKey))
{
if (foundEncryptedPem)
{
throw new ArgumentException(SR.Argument_PemImport_AmbiguousPem, nameof(source));
}

foundEncryptedPem = true;
foundFields = fields;
foundSlice = pem;
}

Index offset = fields.Location.End;
pem = pem[offset..];
}

if (!foundEncryptedPem)
{
throw new ArgumentException(SR.Argument_PemImport_NoPemFound, nameof(source));
}

ReadOnlySpan<char> base64Contents = foundSlice[foundFields.Base64Data];
#if NET
int base64size = foundFields.DecodedDataLength;
byte[] decodeBuffer = CryptoPool.Rent(base64size);
int bytesWritten = 0;

try
{
if (!Convert.TryFromBase64Chars(base64Contents, decodeBuffer, out bytesWritten))
{
// Couldn't decode base64. We shouldn't get here since the
// contents are pre-validated.
Debug.Fail("Base64 decoding failed on already validated contents.");
throw new ArgumentException();
}

Debug.Assert(bytesWritten == base64size);
Span<byte> decodedBase64 = decodeBuffer.AsSpan(0, bytesWritten);

return importAction(password, decodedBase64);
}
finally
{
CryptoPool.Return(decodeBuffer, clearSize: bytesWritten);
}
#else
return importAction(password, Convert.FromBase64String(base64Contents.ToString()));
#endif
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,17 @@ public static void ImportEncryptedPkcs8PrivateKey_NotSupported()
Assert.Throws<PlatformNotSupportedException>(() => MLKem.ImportEncryptedPkcs8PrivateKey(
MLKemTestData.EncryptedPrivateKeyPasswordBytes, MLKemTestData.IetfMlKem512EncryptedPrivateKeySeed));
}

[Fact]
public static void ImportFromPem_NotSupported()
{
string pem = """
-----BEGIN THING-----
Should throw before even attempting to read the PEM
-----END THING-----
""";
Assert.Throws<PlatformNotSupportedException>(() => MLKem.ImportFromPem(pem));
Assert.Throws<PlatformNotSupportedException>(() => MLKem.ImportFromPem(pem.AsSpan()));
}
}
}
Loading
Loading