Skip to content
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

Simplify PEM loading #563

Merged

Conversation

martincostello
Copy link
Member

@martincostello martincostello commented Jun 7, 2021

Simplify the code to load the PEM file for Sign in with Apple after feedback on dotnet/runtime#53807.

The Apple provider already has some breaking changes for .NET 6, so would be a good time to try and simplify it as well to leverage the new PEM support added in .NET 5.

Leaving as draft for now, as if we make this change it might be worth seeing if it can leverage the forthcoming Secret<T> type as it's a key (dotnet/designs#147 (comment), if this is a good use case) which would make it something like this instead:

public Func<string, CancellationToken, Task<Secret<char>>>? PrivateKey { get; set; }

which would then be used at the last-minute like this:

using var pem = await context.Options.PrivateKey!(context.Options.KeyId!, context.HttpContext.RequestAborted);
// ...
algorithm.ImportFromPem(pem.RevealToString());

Might be overkill as it needs to read off disk as a string anyway to use the ImportFromPem() method.

@martincostello
Copy link
Member Author

Secret<T> has been pushed out into 7.0 now. I'll circle back to the original change soon and add into the 6.0 branch.

@martincostello
Copy link
Member Author

Tested with my sample app in Azure with an RC1 daily build and Azure Key Vault and works as expected. Also makes it neater to configure getting the secret from Azure:

using AspNet.Security.OAuth.Apple;
using Azure;
using Azure.Security.KeyVault.Secrets;

namespace Microsoft.Extensions.DependencyInjection;

internal static class AppleAuthenticationOptionsExtensions
{
    public static AppleAuthenticationOptions UseAzureKeyVaultSecret(
        this AppleAuthenticationOptions options,
        Func<string, CancellationToken, Task<Response<KeyVaultSecret>>> secretProvider)
    {
        options.GenerateClientSecret = true;
        options.PrivateKey = async (keyId, cancellationToken) =>
        {
            var secret = await secretProvider(keyId, cancellationToken);
            return secret.Value.Value;
        };

        return options;
    }
}

@martincostello martincostello marked this pull request as ready for review August 25, 2021 14:37
/// associated with the current HTTP request.
/// </summary>
/// <remarks>
/// The private key should be in PKCS #8 (<c>.p8</c>) format.
/// </remarks>
public Func<string, CancellationToken, Task<byte[]>>? PrivateKeyBytes { get; set; }
public Func<string, CancellationToken, Task<string>>? PrivateKey { get; set; }
Copy link
Member

@kevinchalet kevinchalet Aug 25, 2021

Choose a reason for hiding this comment

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

Personally, I've never been a huge fan of representing crypto material as "simple" strings (unpopular opinion: I liked SecureString 😄). Should we instead directly return an ECDsa instance here? Or an AsymmetricAlgorithm if we want to future-proof that delegate to support other algorithms (we never know, Apple may switch back to the good ol' RSA 😄).

If none of these options work for you, I'd probably return a ReadOnlyMemory<char> here.

Copy link
Member Author

Choose a reason for hiding this comment

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

The idea behind this API is/was to just get the raw key material out of the file you get from Apple, but do it in a non-implementation specific way, so you could get it from disk, from Azure Key Vault, from an env var, etc. etc.

The ECDsa is then created internally from the PEM returned above.

https://github.com/martincostello/AspNet.Security.OAuth.Providers/blob/077853134729f67606d710998c77c8f5ea032e71/src/AspNet.Security.OAuth.Apple/Internal/DefaultAppleClientSecretGenerator.cs#L96-L125

Originally I did it with byte[] as it was the lowest-common-denominator, but that lead to ugly hacks like this to be able to create the algorithm from the certificate file:

string privateKey = await reader.ReadToEndAsync();
if (privateKey.StartsWith("-----BEGIN PRIVATE KEY-----", StringComparison.Ordinal))
{
string[] lines = privateKey.Split('\n');
privateKey = string.Join(string.Empty, lines[1..^1]);
}
return Convert.FromBase64String(privateKey);

So that's why I wanted to swap it out string so I could just use ImportFromPem(). I did try with ReadOnlySpan<char>, but it wouldn't let me use that. I didn't think of ReadOnlyMemory<char> though, and locally that seems to work OK.

I'd be happy to change it to ReadOnlyMemory<char> if it's not "too exotic" 😄.

The only downside is that it's slightly less intuitive to use in the Key Vault use case (call to AsMemory() at the end):

public static AppleAuthenticationOptions UseAzureKeyVaultSecret(
    this AppleAuthenticationOptions options,
    Func<string, CancellationToken, Task<Response<KeyVaultSecret>>> secretProvider)
{
    options.GenerateClientSecret = true;
    options.PrivateKey = async (keyId, cancellationToken) =>
    {
        var secret = await secretProvider(keyId, cancellationToken);
        return secret.Value.Value.AsMemory();
    };

    return options;
}

Copy link
Member

Choose a reason for hiding this comment

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

The idea behind this API is/was to just get the raw key material out of the file you get from Apple, but do it in a non-implementation specific way, so you could get it from disk, from Azure Key Vault, from an env var, etc. etc.

Wouldn't moving the responsibility to create the ECDsa to the delegate implementer achieve the same goal?

With your example, it would likely look like something like this:

public static AppleAuthenticationOptions UseAzureKeyVaultSecret(
    this AppleAuthenticationOptions options,
    Func<string, CancellationToken, Task<Response<KeyVaultSecret>>> secretProvider)
{
    options.GenerateClientSecret = true;
    options.PrivateKey = async (keyId, cancellationToken) =>
    {
        var secret = await secretProvider(keyId, cancellationToken);
        var algorithm = ECDsa.Create();
        algorithm.ImportFromPem(secret.Value.Value);
        return algorithm;
    };

    return options;
}

Copy link
Member Author

Choose a reason for hiding this comment

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

While that would work, that just seems like it's offloading more code onto the client by expecting them to also provide the algorithm, rather than just configure the secret.

Given the Apple provider is already "more complicated" that the other popular ones (e.g. Facebook and Google), I wanted to keep it relatively easy for people to just plug-in and go, rather than expose them to the underlying mechanics of how it works.

Today it's pretty much "tell me where the secret is, I'll do the rest". With the above it turns into "do some crypto stuff you might not be familiar with and then I'll use it". I was aiming to find the sweet spot between ease of use and extensibility, and I think exposing the algorithm is a step too far away from usability for the average dev as it's making it less of a black box to deviate even slightly from the default built-in usage mode of providing the file path.

Maybe we could add more built-in extension methods to turn a string/ROM into the algorithm for you as a building block, but exposing the algorithm on the options feels a bit too lower-level to me, as if you wanted to go super-off-the-beaten-path, you could just implement your own AppleClientSecretGenerator anyway.

Copy link
Member

Choose a reason for hiding this comment

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

Makes sense, let's opt for ReadOnlyMemory<char> then 😄

Since you mentioned Azure Key Vault, it's interesting to note that since we're forced to have the private key material, we can't support scenarios where the key material is stored in a HSM and can't be exported (they call that "hardware keys"). But you're likely right, it's an advanced scenario.

Simplify the code to load the PEM file for Sign in with Apple.
Switch to ReadOnlyMemory<char> instead of a plain string.
Update examples for changes in aspnet-contrib#563.
Add example for environment variables and Azure Key Vault.
Fix property type.
Remove direct link to what-will-be-redundant code.
.NET Core 2.1 is out-of-support now, so remove the mentions.
@martincostello
Copy link
Member Author

Updated to ReadOnlyMemory<char> and I made some updates to the documentation too.

@martincostello martincostello merged commit e7938cc into aspnet-contrib:dev-6.0.0 Aug 26, 2021
@martincostello martincostello deleted the Simplify-Key-Loading branch August 26, 2021 07:58
martincostello added a commit that referenced this pull request Aug 26, 2021
Update examples for changes in #563.
Add example for environment variables and Azure Key Vault.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Development

Successfully merging this pull request may close these issues.

2 participants