5

I'm using a private key to sign a JWT token, which works as expected. However, I'd like to leverage Azure Key Vault to do the signing for me, so that the private key doesn't leave KeyVault. I'm struggling to get this to work, but not sure why.

Here's the code that doesn't use KeyVault and does work...

var handler = new JwtSecurityTokenHandler();

var expiryTime = DateTimeOffset.UtcNow.AddMinutes(10).ToUnixTimeSeconds();

var claims = new[]
{
    new Claim(JwtRegisteredClaimNames.Iss, clientId),
    new Claim(JwtRegisteredClaimNames.Sub, integrationUser),
    new Claim(JwtRegisteredClaimNames.Aud, "https://test.example.com"),
    new Claim(JwtRegisteredClaimNames.Exp, expiryTime.ToString()),
    new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) // Add JTI for additional security against replay attacks
};

var privateKey = File.ReadAllText(@"selfsigned.key")
    .Replace("-----BEGIN PRIVATE KEY-----", "")
    .Replace("-----END PRIVATE KEY-----", "");

var privateKeyRaw = Convert.FromBase64String(privateKey);

var provider = new RSACryptoServiceProvider();
provider.ImportPkcs8PrivateKey(new ReadOnlySpan<byte>(privateKeyRaw), out _);
var rsaSecurityKey = new RsaSecurityKey(provider);

var token = new JwtSecurityToken
(
    new JwtHeader(new SigningCredentials(rsaSecurityKey, SecurityAlgorithms.RsaSha256)),
    new JwtPayload(claims)
);

var token = handler.WriteToken(token);

This works, and if I copy the JWT into jwt.io, and also paste the public key - it says that the signature is verified...

enter image description here

The token also works against the API I'm calling too.

However, if signing with KeyVault...

var handler = new JwtSecurityTokenHandler();

var expiryTime = DateTimeOffset.UtcNow.AddMinutes(10).ToUnixTimeSeconds();

var claims = new[]
{
    new Claim(JwtRegisteredClaimNames.Iss, clientId),
    new Claim(JwtRegisteredClaimNames.Sub, integrationUser),
    new Claim(JwtRegisteredClaimNames.Aud, "https://test.example.com"),
    new Claim(JwtRegisteredClaimNames.Exp, expiryTime.ToString()),
    new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) // Add JTI for additional security against replay attacks
};

var header = @"{""alg"":""RS256"",""typ"":""JWT""}";
var payload = JsonConvert.SerializeObject(new JwtPayload(claims));
var headerAndPayload = $"{Base64UrlEncoder.Encode(header)}.{Base64UrlEncoder.Encode(payload)}";

// Sign token

var credential = new InteractiveBrowserCredential();

var client = new KeyClient(vaultUri: new Uri(kvUri), credential);
var key = (KeyVaultKey)client.GetKey("dan-test");

var cryptoClient = new CryptographyClient(keyId: key.Id, credential);

var digest = new SHA256CryptoServiceProvider().ComputeHash(Encoding.Unicode.GetBytes(headerAndPayload));
var signature = await cryptoClient.SignAsync(SignatureAlgorithm.RS256, digest);

var token = $"{headerAndPayload}.{Base64UrlEncoder.Encode(signature.Signature)}";

(uses Azure.Security.KeyVault.Keys and Azure.Identity nuget packages)

This doesn't work. The first two parts of the token - ie. header and payload are identical to the JWT that does work. The only thing that's different is the signature at the end.

I'm out of ideas! Note that this is closely related to this Stackoverflow question, where the answers seem to suggest what I'm doing should be correct.

enter image description here

Dan
  • 5,692
  • 3
  • 35
  • 66
  • There is a couple of thing's that could be the issue here, I suggest you ask this on github, you will get direct access to team and get an authoritive answer – TheGeneral Sep 09 '21 at 11:25
  • Thanks. I've posted on there and linked to this question. https://github.com/Azure/azure-sdk-for-net/issues/23919 – Dan Sep 09 '21 at 13:12

2 Answers2

6

Your code is mostly correct, though you should use either Encoding.UTF8 or Encoding.ASCII (since the base64url characters are all valid ASCII and you eliminate any BOM concerns) to get the bytes for headerAndPayload.

I was able to get this to work and found that https://jwt.io is rather vague when it says you can paste either a public key or certificate. It has to be PEM-encoded, and if posting an RSA public key you have to use the less-common "BEGIN RSA PUBLIC KEY" label instead of the more-common "BEGIN PUBLIC KEY".

I tried a few things that all should've worked, and when I found that using a certificate from Key Vault did with "BEGIN CERTIFICATE", I went back to trying "BEGIN PUBLIC KEY". It wasn't until, on a whim, when I changed it to "BEGIN RSA PUBLIC KEY" the JWT was successfully verified.

Below is the code I tried using certificate URI:

using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using Azure.Identity;
using Azure.Security.KeyVault.Certificates;
using Azure.Security.KeyVault.Keys;
using Azure.Security.KeyVault.Keys.Cryptography;
using Microsoft.IdentityModel.Tokens;

var arg = args.Length > 0 ? args[0] : throw new Exception("Key Vault key URI required");
var uri = new Uri(arg, UriKind.Absolute);

var claims = new[]
{
    new Claim(JwtRegisteredClaimNames.Iss, Guid.NewGuid().ToString()),
    new Claim(JwtRegisteredClaimNames.Aud, "https://test.example.com"),
    new Claim(JwtRegisteredClaimNames.Exp, DateTimeOffset.Now.AddMinutes(10).ToUnixTimeSeconds().ToString()),
    new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};

var header = @"{""alg"":""RS256"",""typ"":""JWT""}";
var payload = JsonSerializer.Serialize(new JwtPayload(claims));
var headerAndPayload = $"{Base64UrlEncoder.Encode(header)}.{Base64UrlEncoder.Encode(payload)}";

var id = new KeyVaultKeyIdentifier(uri);
var credential = new DefaultAzureCredential();

var certClient = new CertificateClient(id.VaultUri, credential);
KeyVaultCertificate cert = await certClient.GetCertificateAsync(id.Name);
using X509Certificate2 pfx = await certClient.DownloadCertificateAsync(id.Name, id.Version);

var pem = PemEncoding.Write("CERTIFICATE".AsSpan(), pfx.RawData);
Console.WriteLine($"Certificate (PEM):\n");
Console.WriteLine(pem);
Console.WriteLine();

using var rsaKey = pfx.GetRSAPublicKey();
var pubkey = rsaKey.ExportRSAPublicKey();
pem = PemEncoding.Write("RSA PUBLIC KEY".AsSpan(), pubkey.AsSpan());
Console.WriteLine($"Public key (PEM):\n");
Console.WriteLine(pem);
Console.WriteLine();

var cryptoClient = new CryptographyClient(cert.KeyId, credential);

using var sha256 = SHA256.Create();
var digest = sha256.ComputeHash(Encoding.ASCII.GetBytes(headerAndPayload));
var signature = (await cryptoClient.SignAsync(SignatureAlgorithm.RS256, digest)).Signature;

var token = $"{headerAndPayload}.{Base64UrlEncoder.Encode(signature)}";
Console.WriteLine($"JWT:\n\n{token}");

For using only a key, the following should work:

using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Azure.Identity;
using Azure.Security.KeyVault.Keys;
using Azure.Security.KeyVault.Keys.Cryptography;
using Microsoft.IdentityModel.Tokens;

var arg = args.Length > 0 ? args[0] : throw new Exception("Key Vault key URI required");
var uri = new Uri(arg, UriKind.Absolute);

var claims = new[]
{
    new Claim(JwtRegisteredClaimNames.Iss, Guid.NewGuid().ToString()),
    new Claim(JwtRegisteredClaimNames.Aud, "https://test.example.com"),
    new Claim(JwtRegisteredClaimNames.Exp, DateTimeOffset.Now.AddMinutes(10).ToUnixTimeSeconds().ToString()),
    new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};

var header = @"{""alg"":""RS256"",""typ"":""JWT""}";
var payload = JsonSerializer.Serialize(new JwtPayload(claims));
var headerAndPayload = $"{Base64UrlEncoder.Encode(header)}.{Base64UrlEncoder.Encode(payload)}";

var id = new KeyVaultKeyIdentifier(uri);
var credential = new DefaultAzureCredential();

var keyClient = new KeyClient(id.VaultUri, credential);
KeyVaultKey key = await keyClient.GetKeyAsync(id.Name, id.Version);

using var rsaKey = key.Key.ToRSA();
var pubkey = rsaKey.ExportRSAPublicKey();
var pem = PemEncoding.Write("RSA PUBLIC KEY".AsSpan(), pubkey.AsSpan());
Console.WriteLine($"Public key (PEM):\n");
Console.WriteLine(pem);
Console.WriteLine();

var cryptoClient = new CryptographyClient(key.Id, credential);

using var sha256 = SHA256.Create();
var digest = sha256.ComputeHash(Encoding.ASCII.GetBytes(headerAndPayload));
var signature = (await cryptoClient.SignAsync(SignatureAlgorithm.RS256, digest)).Signature;

var token = $"{headerAndPayload}.{Base64UrlEncoder.Encode(signature)}";
Console.WriteLine($"JWT:\n\n{token}");
Heath
  • 2,986
  • 18
  • 21
  • 1
    Brilliant - thank you! It was just the changing from `Encoding.Unicode.` to `Encoding.ASCII.` that fixed it for me. Amazing how a single word can cause so many problems – Dan Sep 10 '21 at 06:53
  • 1
    How can we sign JWT using private key instead of public key? – MARKAND Bhatt Jan 05 '22 at 09:35
  • 1
    Signing is always done with a private key, and in this example is done by Key Vault which, by default, does not expose the private key. – Heath Jan 07 '22 at 01:49
3

To generate token you can created your own implementation of CryptoProviderFactory in SigningCredentials.

var credentials = new SigningCredentials(new RsaSecurityKey(RSA.Create()), algorithm: SecurityAlgorithms.RsaSha256);
credentials.CryptoProviderFactory = _cryptoProviderFactory;
var descriptor = new SecurityTokenDescriptor
{
    Subject = _owinContext.Request.User.Identity as ClaimsIdentity,
    Expires = DateTime.UtcNow.AddHours(4),
    SigningCredentials = credentials,
    Audience = _configuration.AccessTokenAudience,
    Issuer = _configuration.AccessTokenIssuer,
    IssuedAt = DateTime.UtcNow,
};

var token = tokenHandler.CreateToken(descriptor);

SignatureProviderFactory implementation:

public class CustomCryptoProviderFactory : CryptoProviderFactory
    {
        private readonly CryptographyClient _cryptoClient;

        public CustomCryptoProviderFactory()
        {
            var client = new KeyClient(new Uri("{url}"), new DefaultAzureCredential());
            var key = client.GetKey("{key-name}");
            _cryptoClient = new CryptographyClient(new Uri(key.Value.Key.Id), new DefaultAzureCredential());
        }

        public override SignatureProvider CreateForSigning(SecurityKey key, string algorithm)
        {
            return new CustomSignatureProvider(_cryptoClient, key, algorithm);
        }

        public override SignatureProvider CreateForSigning(SecurityKey key, string algorithm, bool cacheProvider)
        {
            return new CustomSignatureProvider(_cryptoClient, key, algorithm);
        }

        public override SignatureProvider CreateForVerifying(SecurityKey key, string algorithm)
        {
            return new CustomSignatureProvider(_cryptoClient, key, algorithm;
        }

        public override SignatureProvider CreateForVerifying(SecurityKey key, string algorithm, bool cacheProvider)
        {
            return new CustomSignatureProvider(_cryptoClient, key, algorithm);
        }
    }

CustomSignatureProvider implementation

public class CustomSignatureProvider : SignatureProvider
    {
        private readonly CryptographyClient _cryptoClient;

        public CustomSignatureProvider(CryptographyClient cryptoClient,
            SecurityKey key,
            string algorithm)
            : base(key, algorithm)
        {
            _cryptoClient = cryptoClient;
        }

        public override byte[] Sign(byte[] input)
        {


            var result = _cryptoClient.Sign(SignatureAlgorithm.RS256, GetSHA256(input));

            return result.Signature;
        }

        public override bool Verify(byte[] input, byte[] signature)
        {
            var verificationResult = _cryptoClient.Verify(SignatureAlgorithm.RS256,
                GetSHA256(input),
                signature);

            return verificationResult.IsValid;
        }

        protected override void Dispose(bool disposing)
        {
        }

        private byte[] GetSHA256(byte[] input)
        {
            var sha = SHA256.Create();
            return sha.ComputeHash(input);
        }
    }
  • This can be further improved by moving the setup of the crypto provider in to a new AzureSecurityKey class, at which point the custom crypto provider factory can be wired up by the constructor of an AzureSecurityKey. – AJ Henderson Feb 09 '23 at 18:30