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

feat: add .NET verion generate-signature command #91

Merged
merged 23 commits into from
Apr 24, 2023
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Notation.Plugin.AzureKeyVault/Certificate/CertificateBundle.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Security.Cryptography.X509Certificates;

namespace Notation.Plugin.AzureKeyVault.Certificate
JeyJeyGao marked this conversation as resolved.
Show resolved Hide resolved
{
/// <summary>
/// Helper class to create a certificate bundle from a PEM file.
/// </summary>
static class CertificateBundle
{
/// <summary>
/// Create a certificate bundle from a PEM file.
/// </summary>
public static X509Certificate2Collection Create(string pemFilePath)
JeyJeyGao marked this conversation as resolved.
Show resolved Hide resolved
JeyJeyGao marked this conversation as resolved.
Show resolved Hide resolved
{
var certificates = new X509Certificate2Collection();
certificates.ImportFromPemFile(pemFilePath);
return certificates;
}
}
}
49 changes: 49 additions & 0 deletions Notation.Plugin.AzureKeyVault/Certificate/CertificateChain.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Security.Cryptography.X509Certificates;
using Notation.Plugin.Protocol;

namespace Notation.Plugin.AzureKeyVault.Certificate
{
/// <summary>
/// Helper class to build certificate chain.
/// </summary>
static class CertificateChain
{
/// <summary>
/// Build a certificate chain from a leaf certificate and a
/// certificate bundle.
///
/// <param name="certificateBundle">The certificate bundle.</param>
/// <param name="leafCert">The leaf certificate.</param>
/// <returns>A list of raw certificates in a chain.</returns>
/// </summary>
public static List<byte[]> Build(X509Certificate2 leafCert, X509Certificate2Collection certificateBundle)
{
X509Chain chain = new X509Chain();
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.CustomTrustStore.AddRange(certificateBundle);

bool isValid = chain.Build(leafCert);
if (!isValid)
{
throw new ValidationException("Certificate is invalid");
}

foreach (X509ChainStatus status in chain.ChainStatus)
{
if (status.Status == X509ChainStatusFlags.PartialChain)
{
throw new ValidationException("Failed to build the X509 chain up to the root certificate. To resolve this issue, provide the intermediate and root certificates by passing the certificate bundle file's path to the `ca_certs` key in the pluginConfig");
}

if (status.Status != X509ChainStatusFlags.NoError && status.Status != X509ChainStatusFlags.UntrustedRoot)
{
throw new ValidationException($"Failed to build the X509 chain due to {status.StatusInformation}");
}
}

return chain.ChainElements.Select(x => x.Certificate.RawData).ToList();
}
}
}
46 changes: 6 additions & 40 deletions Notation.Plugin.AzureKeyVault/Command/DescribeKey.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using Azure.Identity;
using Azure.Security.KeyVault.Certificates;
using Notation.Plugin.Protocol;
using Notation.Plugin.AzureKeyVault.Client;

namespace Notation.Plugin.AzureKeyVault.Command
{
Expand All @@ -22,45 +20,13 @@ public async Task<object> RunAsync(string inputJson)
throw new ValidationException(invalidInputError);
}

// example uri: https://notationakvtest.vault.azure.net/keys/notationev10leafcert/847956cbd58c4937ab04d8ab8622000c
var uri = new Uri(request.KeyId);
// Get certificate from Azure Key Vault
var akvClient = new KeyVaultClient(request.KeyId);
var cert = await akvClient.GetCertificate();

// validate uri
if (uri.Segments.Length != 4)
{
throw new ValidationException(invalidInputError);
}
if (uri.Segments[1] != "keys/" && uri.Segments[1] != "certificates/")
{
throw new ValidationException(invalidInputError);
}
if (uri.Scheme != "https")
{
throw new ValidationException(invalidInputError);
}
// extract keys|certificates name from the uri
var name = uri.Segments[2].TrimEnd('/');
var version = uri.Segments[3].TrimEnd('/');

// generate a certificate client
// TODO - This will be refactored when generate-signature command is
// implemented.
var credential = new AzureCliCredential();
var dnsUri = new Uri($"{uri.Scheme}://{uri.Host}");
var certificateClient = new CertificateClient(dnsUri, credential);

// parse the certificate to be X509Certificate2
var cert = await certificateClient.GetCertificateVersionAsync(name, version);
var x509 = new X509Certificate2(cert.Value.Cer);

// extract key spec from the certificate
var keySpec = KeySpecUtils.ExtractKeySpec(x509);
var encodedKeySpec = KeySpecUtils.EncodeKeySpec(keySpec);

// Serialize DescribeKeyResponse object to JSON string
return new DescribeKeyResponse(
keyId: request.KeyId,
keySpec: encodedKeySpec);
keySpec: cert.KeySpec().EncodeKeySpec());
}
}
}
}
60 changes: 60 additions & 0 deletions Notation.Plugin.AzureKeyVault/Command/GenerateSignature.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using Notation.Plugin.AzureKeyVault.Certificate;
using Notation.Plugin.AzureKeyVault.Client;
using Notation.Plugin.Protocol;

namespace Notation.Plugin.AzureKeyVault.Command
{
/// <summary>
/// Implementation of generate-signature command.
/// </summary>
public class GenerateSignature : IPluginCommand
{
public async Task<object> RunAsync(string inputJson)
{
// Parse the input
GenerateSignatureRequest? input = JsonSerializer.Deserialize<GenerateSignatureRequest>(inputJson);
if (input == null)
{
throw new ValidationException("Invalid input");
}

var akvClient = new KeyVaultClient(input.KeyId);

// Extract signature algorithm from the certificate
var leafCert = await akvClient.GetCertificate();
var keySpec = leafCert.KeySpec();
var signatureAlgorithm = keySpec.ToSignatureAlgorithm();

// Sign
var signature = await akvClient.Sign(signatureAlgorithm, input.Payload);
JeyJeyGao marked this conversation as resolved.
Show resolved Hide resolved

// Build the certificate chain
List<byte[]> certificateChain = new List<byte[]>();
if (input.PluginConfig?.ContainsKey("ca_certs") ?? false)
JeyJeyGao marked this conversation as resolved.
Show resolved Hide resolved
{
// Build the entire certificate chain from the certificate
// bundle (including the intermediate and root certificates).
var caCertsPath = input.PluginConfig["ca_certs"];
certificateChain = CertificateChain.Build(leafCert, CertificateBundle.Create(caCertsPath));
}
else if (input.PluginConfig?.ContainsKey("as_secret") ?? false)
{
// Read the entire certificate chain from the Azure Key Vault with GetSecret permission.
throw new NotImplementedException("as_secret is not implemented yet");
}
else
{
// validate the self-signed leaf certificate
certificateChain = CertificateChain.Build(leafCert, new X509Certificate2Collection());
}

return new GenerateSignatureResponse(
keyId: input.KeyId,
signature: signature,
signingAlgorithm: keySpec.ToSigningAlgorithm(),
certificateChain: certificateChain);
}
}
}
4 changes: 2 additions & 2 deletions Notation.Plugin.AzureKeyVault/Command/GetPluginMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ public class GetPluginMetadata : IPluginCommand
public async Task<object> RunAsync(string _)
{
return await Task.FromResult<object>(new GetMetadataResponse(
name: "akv-plugin",
name: "azure-kv",
description: "Notation Azure Key Vault plugin",
version: "1.0.0",
url: "https://github.com/Azure/notation-azure-kv",
supportedContractVersions: new[] { "1.0.0" },
supportedContractVersions: new[] { Protocol.Protocol.ContractVersion },
capabilities: new[] { "SIGNATURE_GENERATOR.RAW" }
));
}
Expand Down
34 changes: 34 additions & 0 deletions Notation.Plugin.AzureKeyVault/KeyVault/KeySpecExtension.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Azure.Security.KeyVault.Keys.Cryptography;
using Notation.Plugin.Protocol;

namespace Notation.Plugin.AzureKeyVault.Client
{
/// <summary>
/// Extension class to get SignatureAlgorithm from KeySpec.
/// </summary>
public static class KeySpecExtension
{

/// <summary>
/// Get SignatureAlgorithm from KeySpec for Azure Key Vault signing.
/// </summary>
public static SignatureAlgorithm ToSignatureAlgorithm(this KeySpec keySpec) => keySpec.Type switch
{
KeyType.RSA => keySpec.Size switch
{
2048 => SignatureAlgorithm.PS256,
3072 => SignatureAlgorithm.PS384,
4096 => SignatureAlgorithm.PS512,
_ => throw new ArgumentException($"Invalid KeySpec for RSA with size {keySpec.Size}")
},
KeyType.EC => keySpec.Size switch
{
256 => SignatureAlgorithm.ES256,
384 => SignatureAlgorithm.ES384,
521 => SignatureAlgorithm.ES512,
_ => throw new ArgumentException($"Invalid KeySpec for EC with size {keySpec.Size}")
},
_ => throw new ArgumentException($"Invalid KeySpec with type {keySpec.Type}")
};
}
}
149 changes: 149 additions & 0 deletions Notation.Plugin.AzureKeyVault/KeyVault/KeyVaultClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
using System.Security.Cryptography.X509Certificates;
using Azure.Identity;
using Azure.Security.KeyVault.Certificates;
using Azure.Security.KeyVault.Keys.Cryptography;
using Notation.Plugin.Protocol;

namespace Notation.Plugin.AzureKeyVault.Client
{
class KeyVaultClient
{
/// <summary>
/// A helper record to store KeyVault metadata.
/// </summary>
private record KeyVaultMetadata(string KeyVaultUrl, string Name, string Version);

// Certificate client (lazy initialization)
private Lazy<CertificateClient> _certificateClient;
// Cryptography client (lazy initialization)
private Lazy<CryptographyClient> _cryptoClient;
// Error message for invalid input
private const string INVALID_INPUT_ERROR_MSG = "Invalid input. The valid input format is '{\"contractVersion\":\"1.0\",\"keyId\":\"https://<vaultname>.vault.azure.net/<keys|certificate>/<name>/<version>\"}'";

// Key name or certificate name
public string _name;
// Key version or certificate version
public string _version;
// Key identifier (e.g. https://<vaultname>.vault.azure.net/keys/<name>/<version>)
public string _keyId;

/// <summary>
/// Constructor to create AzureKeyVault object from keyVaultUrl, name
/// and version.
/// </summary>
public KeyVaultClient(string keyVaultUrl, string name, string version)
{
if (string.IsNullOrEmpty(keyVaultUrl))
{
throw new ArgumentNullException(nameof(keyVaultUrl), "KeyVaultUrl must not be null or empty");
}

if (string.IsNullOrEmpty(name))
{
throw new ArgumentNullException(nameof(name), "KeyName must not be null or empty");
}

if (string.IsNullOrEmpty(version))
{
throw new ArgumentNullException(nameof(version), "KeyVersion must not be null or empty");
}

this._name = name;
this._version = version;
this._keyId = $"{keyVaultUrl}/keys/{name}/{version}";

// initialize credential and lazy clients
var credential = new DefaultAzureCredential();
this._certificateClient = new Lazy<CertificateClient>(() => new CertificateClient(new Uri(keyVaultUrl), credential));
this._cryptoClient = new Lazy<CryptographyClient>(() => new CryptographyClient(new Uri(_keyId), credential));
}

/// <summary>
/// Constructor to create AzureKeyVault object from key identifier or
/// certificate identifier.
///
/// <param name="id">
/// Key identifier or certificate identifier. (e.g. https://<vaultname>.vault.azure.net/keys/<name>/<version>)
/// </param>
/// </summary>
public KeyVaultClient(string id) : this(ParseId(id)) { }

/// <summary>
/// A helper constructor to create KeyVaultClient from KeyVaultMetadata.
/// </summary>
private KeyVaultClient(KeyVaultMetadata metadata)
: this(metadata.KeyVaultUrl, metadata.Name, metadata.Version) { }

/// <summary>
/// A helper function to parse key identifier or certificate identifier
/// and return KeyVaultMetadata.
/// </summary>
private static KeyVaultMetadata ParseId(string id)
{
if (string.IsNullOrEmpty(id))
{
throw new ArgumentNullException(nameof(id), "Id must not be null or empty");
}

var uri = new Uri(id);
// Validate uri
if (uri.Segments.Length != 4)
{
throw new ValidationException(INVALID_INPUT_ERROR_MSG);
}

if (uri.Segments[1] != "keys/" && uri.Segments[1] != "certificates/")
{
throw new ValidationException(INVALID_INPUT_ERROR_MSG);
}

if (uri.Scheme != "https")
{
throw new ValidationException(INVALID_INPUT_ERROR_MSG);
}

return new KeyVaultMetadata(
KeyVaultUrl: $"{uri.Scheme}://{uri.Host}",
Name: uri.Segments[2].TrimEnd('/'),
Version: uri.Segments[3].TrimEnd('/')
);
}

/// <summary>
/// Sign the payload and return the signature.
/// </summary>
public async Task<byte[]> Sign(SignatureAlgorithm algorithm, byte[] payload)
JeyJeyGao marked this conversation as resolved.
Show resolved Hide resolved
{
var signResult = await _cryptoClient.Value.SignDataAsync(algorithm, payload);
if (signResult.KeyId != _keyId)
{
throw new PluginException($"Invalid keys identifier. The user provides {_keyId} but the response contains {signResult.KeyId} as the keys");
}

if (signResult.Algorithm != algorithm)
{
throw new PluginException($"Invalid signature algorithm. The user provides {algorithm} but the response contains {signResult.Algorithm} as the algorithm");
}

return signResult.Signature;
}

/// <summary>
/// Get the certificate from the key vault.
/// </summary>
public async Task<X509Certificate2> GetCertificate()
JeyJeyGao marked this conversation as resolved.
Show resolved Hide resolved
{
var cert = await _certificateClient.Value.GetCertificateVersionAsync(_name, _version);

// If the version is invalid, the cert will be fallback to
// the latest. So if the version is not the same as the
// requested version, it means the version is invalid.
if (cert.Value.Properties.Version != _version)
{
throw new ValidationException($"Invalid certificate version. The user provides {_version} but the response contains {cert.Value.Properties.Version} as the version");
}

return new X509Certificate2(cert.Value.Cer);
}
}
}
Loading