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 1 commit
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
28 changes: 4 additions & 24 deletions Notation.Plugin.AzureKeyVault/Certificate/CertificateBundle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,36 +5,16 @@ namespace Notation.Plugin.AzureKeyVault.Certificate
/// <summary>
/// Helper class to create a certificate bundle from a PEM file.
/// </summary>
class CertificateBundle
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
{
// Split the PEM file into certificates.
string pemContent = File.ReadAllText(pemFilePath);
string[] pemCertificates = pemContent.Split("-----END CERTIFICATE-----", StringSplitOptions.RemoveEmptyEntries & StringSplitOptions.TrimEntries);

// Add the certificates to the bundle.
var certs = pemCertificates.Select(x => ConvertPemToDer(x))
.Where(x => x != null && x.Length > 0)
.Select(x => new X509Certificate2(x));
return new X509Certificate2Collection(certs.ToArray());
}

/// <summary>
/// Convert PEM to DER. It removes the header and footer of the PEM
/// file, merges multiple lines into one and decodes the base64 string.
/// </summary>
private static byte[] ConvertPemToDer(string pem)
{
// Remove the header and footer of the PEM file.
var lines = pem.Split('\n', StringSplitOptions.RemoveEmptyEntries & StringSplitOptions.TrimEntries)
.Where(x => !x.StartsWith("-----"));

// Merge multiple lines into one.
return Convert.FromBase64String(String.Join("", lines));
var certificates = new X509Certificate2Collection();
certificates.ImportFromPemFile(pemFilePath);
return certificates;
}
}
}
21 changes: 17 additions & 4 deletions Notation.Plugin.AzureKeyVault/Certificate/CertificateChain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace Notation.Plugin.AzureKeyVault.Certificate
/// <summary>
/// Helper class to build certificate chain.
/// </summary>
class CertificateChain
static class CertificateChain
{
/// <summary>
/// Build a certificate chain from a leaf certificate and a
Expand All @@ -16,20 +16,33 @@ class CertificateChain
/// <param name="leafCert">The leaf certificate.</param>
/// <returns>A list of raw certificates in a chain.</returns>
/// </summary>
public static List<byte[]> Build(X509Certificate2Collection certificateBundle, X509Certificate2 leafCert)
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);
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;

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();
}
}
Expand Down
8 changes: 2 additions & 6 deletions Notation.Plugin.AzureKeyVault/Command/DescribeKey.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,9 @@ public async Task<object> RunAsync(string inputJson)
var akvClient = new KeyVaultClient(request.KeyId);
var cert = await akvClient.GetCertificate();

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

return new DescribeKeyResponse(
keyId: request.KeyId,
keySpec: encodedKeySpec);
keySpec: cert.KeySpec().EncodeKeySpec());
}
}
}
}
19 changes: 10 additions & 9 deletions Notation.Plugin.AzureKeyVault/Command/GenerateSignature.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using Notation.Plugin.AzureKeyVault.Certificate;
using Notation.Plugin.AzureKeyVault.Client;
Expand All @@ -6,7 +7,7 @@
namespace Notation.Plugin.AzureKeyVault.Command
{
/// <summary>
/// Implementation of describe-key command.
/// Implementation of generate-signature command.
/// </summary>
public class GenerateSignature : IPluginCommand
{
Expand All @@ -23,36 +24,36 @@ public async Task<object> RunAsync(string inputJson)

// Extract signature algorithm from the certificate
var leafCert = await akvClient.GetCertificate();
var keySpec = KeySpecUtils.ExtractKeySpec(leafCert);
var signatureAlgorithm = SignatureAlgorithmHelper.FromKeySpec(keySpec);
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 != null && input.PluginConfig.ContainsKey("ca_certs"))
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(CertificateBundle.Create(caCertsPath), leafCert);
certificateChain = CertificateChain.Build(leafCert, CertificateBundle.Create(caCertsPath));
}
else if (input.PluginConfig != null && input.PluginConfig.ContainsKey("as_secret"))
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
{
// Self-signed leaf certificate
certificateChain.Add(leafCert.RawData);
// validate the self-signed leaf certificate
certificateChain = CertificateChain.Build(leafCert, new X509Certificate2Collection());
}

return new GenerateSignatureResponse(
keyId: input.KeyId,
signature: signature,
signingAlgorithm: KeySpecUtils.ToSigningAlgorithm(keySpec),
signingAlgorithm: keySpec.ToSigningAlgorithm(),
certificateChain: certificateChain);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
using Azure.Security.KeyVault.Keys.Cryptography;
using KeyType = Notation.Plugin.Protocol.KeyType;
using Notation.Plugin.Protocol;

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

/// <summary>
/// Get SignatureAlgorithm from KeySpec for Azure Key Vault signing.
/// </summary>
public static SignatureAlgorithm FromKeySpec(KeySpec keySpec) => keySpec.Type switch
public static SignatureAlgorithm ToSignatureAlgorithm(this KeySpec keySpec) => keySpec.Type switch
{
KeyType.RSA => keySpec.Size switch
{
Expand All @@ -31,4 +31,4 @@ class SignatureAlgorithmHelper
_ => throw new ArgumentException($"Invalid KeySpec with type {keySpec.Type}")
};
}
}
}
101 changes: 70 additions & 31 deletions Notation.Plugin.AzureKeyVault/KeyVault/KeyVaultClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,55 @@ namespace Notation.Plugin.AzureKeyVault.Client
{
class KeyVaultClient
{
// Key name or certificate name
private string _name;
// Key version or certificate version
private string _version;
// Key identifier (e.g. https://<vaultname>.vault.azure.net/keys/<name>/<version>)
private string _keyId;
/// <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>\"}'";

private const string invalidInputErrorMessage = "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
Expand All @@ -29,7 +66,19 @@ class KeyVaultClient
/// Key identifier or certificate identifier. (e.g. https://<vaultname>.vault.azure.net/keys/<name>/<version>)
/// </param>
/// </summary>
public KeyVaultClient(string id)
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))
{
Expand All @@ -40,50 +89,40 @@ public KeyVaultClient(string id)
// Validate uri
if (uri.Segments.Length != 4)
{
throw new ValidationException(invalidInputErrorMessage);
throw new ValidationException(INVALID_INPUT_ERROR_MSG);
}

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

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

// Extract keys|certificates name from the uri
this._name = uri.Segments[2].TrimEnd('/');
this._version = uri.Segments[3].TrimEnd('/');
var keyVaultUrl = $"{uri.Scheme}://{uri.Host}";
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));
return new KeyVaultMetadata(
KeyVaultUrl: $"{uri.Scheme}://{uri.Host}",
Name: uri.Segments[2].TrimEnd('/'),
Version: uri.Segments[3].TrimEnd('/')
);
}

/// <summary>
/// Constructor to create AzureKeyVault object from keyVaultUrl, name
/// and version.
/// </summary>
public KeyVaultClient(string keyVaultUrl, string name, string version) : this($"{keyVaultUrl}/keys/{name}/{version}") { }

/// <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 or certificates identifier.");
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.");
throw new PluginException($"Invalid signature algorithm. The user provides {algorithm} but the response contains {signResult.Algorithm} as the algorithm");
}

return signResult.Signature;
Expand All @@ -101,7 +140,7 @@ public async Task<X509Certificate2> GetCertificate()
// requested version, it means the version is invalid.
if (cert.Value.Properties.Version != _version)
{
throw new ValidationException("Invalid certificate 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);
Expand Down
42 changes: 42 additions & 0 deletions Notation.Plugin.AzureKeyVault/Protocol/CertificateExtension.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;

namespace Notation.Plugin.Protocol
{
public static class CertificateExtension
{
/// <summary>
/// Extracts the key spec from the certificate.
/// Supported key types are RSA with key size 2048, 3072, 4096
/// and ECDSA with key size 256, 384, 521.
///
/// <returns>The extracted key spec</returns>
/// </summary>
public static KeySpec KeySpec(this X509Certificate2 certificate)
{
RSA? rsaKey = certificate.GetRSAPublicKey();
if (rsaKey != null)
{
if (rsaKey.KeySize is 2048 or 3072 or 4096)
{
return new KeySpec(KeyType.RSA, rsaKey.KeySize);
}

throw new ValidationException($"RSA key size {rsaKey.KeySize} bits is not supported");
}

ECDsa? ecdsaKey = certificate.GetECDsaPublicKey();
if (ecdsaKey != null)
{
if (ecdsaKey.KeySize is 256 or 384 or 521)
{
return new KeySpec(KeyType.EC, ecdsaKey.KeySize);
}

throw new ValidationException($"ECDSA key size {ecdsaKey.KeySize} bits is not supported");
}

throw new ValidationException("Unsupported public key type");
}
}
}
Loading