Skip to content

JSON Web Signature JWS

Aleksandar Gyonov edited this page Jun 21, 2023 · 10 revisions

Background

The JSON Web Signature (JWS) is a standard for signing JSON objects. It is defined in RFC 7515.

There are other complimentary standards around JWS, such as:

  • RFC 7516 for encryption, so called JWE.
  • RFC 7517 for key management, so called JWK.
  • RFC 7518 for algorithm identification in use with JSON.
  • RFC 7797 for Unencoded Payload Option

The current library is focussed on JWS and JWK (at least for the moment).

A JSON object may be several different things, like most well know JSON Web Token (JWT), but also it can be any valid JSON data object that represents any meaningful business entity / entities.

The library tries to be as generic as possible, so it can be used for any JSON object, for example it is not focused on JWTs (as there are many good libraries that deal with JWTs).

Main Features (Areas of Focus)

The library has 2 (two) main namespaces to deal with JWS and JWK - the CryptoEx.JWS and the CryptoEx.JWK.

JSON Web Key (JWK)

The auxiliary namespace CryptoEx.JWK tries to implement the RFC 7517 as close as possible.

It's primary function is to provide a way to convert .NET cryptographic keys (RSA, ECDSA, HMAC) to and from JWKs and JSON Key Set format.

The library defines an Jwk object and child classes JwkRSA, JwkEc, JwkSymmetric.

It also provides JSON Convertors for them, so they can be easily serialized and deserialized to and from a JSON object.

For the purpose of conversion of the JWK object to and from RSA, ECDSA and HMAC objects it provides the following extension methods, defined in the class CryptoEx.JWK.JwkExtentions:

/// <summary>
/// Gets the RSA public key from the jwk or null if the jwk does not have an RSA public key.
/// </summary>
public static RSA? GetRSAPublicKey(this Jwk jwk);

/// <summary>
/// Gets the RSA private / public key from the jwk or null if the jwk does not have an RSA public key.
/// </summary>
public static RSA? GetRSAPrivateKey(this Jwk jwk);
/// <summary>
/// Gets the ECDsa public key from the jwk or null if the jwk does not have an ECDsa public key.
/// </summary>
public static ECDsa? GetECDsaPublicKey(this Jwk jwk);

/// <summary>
/// Gets the ECDsa private key from the jwk or null if the jwk does not have an ECDsa private key.
/// </summary>
public static ECDsa? GetECDsaPrivateKey(this Jwk jwk);

/// <summary>
/// Gets the symmetric key from the Jwk or null if the jwk does not have an symmetric key.
/// </summary>
public static byte[]? GetSymmetricKey(this Jwk jwk);

/// <summary>
/// Get JWK from RSA or null if not possible. By default does not export the private key
/// </summary>
public static JwkRSA? GetJwk(this RSA key, bool includePrivate = false);

/// <summary>
/// Get JWK from ECDsa or null if not possible. By default does not export the private key
/// </summary>
public static JwkEc? GetJwk(this ECDsa key, bool includePrivate = false);

/// <summary>
///  Get JWK from private (HMAC) or null if not possible.
/// </summary>
public static JwkSymmetric? GetJwk(this byte[] key);

/// <summary>
/// Set some certificate info into the Jwk
/// </summary>
public static void SetX509Certificate(this Jwk jwk, X509Certificate2 cert);
/// <summary>
/// Set some certificates info into the Jwk
/// </summary>
public static void SetX509Certificate(this Jwk jwk, List<X509Certificate2> certs);

/// <summary>
/// Get certificates from JWK
/// </summary>
public static List<X509Certificate2>? GetX509Certificates(this Jwk jwk);

To use these you can follow steps like these:

 // Get the JSON web key (EcDSA key)
 JwkEc? jwk = JsonSerializer.Deserialize<Jwk>(JWK_PRIVATE_1, JwkConstants.jsonOptions) as JwkEc;
 
 // Get the .NET ECDsa key
 ECDsa? ecdsa = jwk.GetECDsaPrivateKey();

NB For the full list of examples, please see the class CryptoEx.Tests.TestJWK.

JSON Web Signature (JWS)

The current library does implement a class - JWSSigner that allows you to sign and verify JSON documents according to the JWS standards.

The class is ready for use. And in the current Wiki topic it is shown how to use it.

It is a complete implementation of the basic JWS standard. It is NOT a complete implementation with all of it's possible extensions !

BUT, it is a good starting point for your own implementation, if you need some more specific or edge case. You can extend the class and override the methods that you need to change. Or you can write your own logic and use the class as a reference / example.

NB As an example of this approach and practical use-case, the current library implements the EU's advanced electronic signature for JSON - the (jAdES) standard. For more information, please see the latter topic in this Wiki - ETSI JSON Signatures.

JWSSigner class

To sign or to verify a JSON document, you need to create an instance of the ** CryptoEx.JWS.JWSSigner** class:

/// <summary>
/// General JOSE signer. This class can be used for signing and verification of modes JWS.
/// It can also be easilly extended to support other modes. For example, see ETSI JOSE signer in current project.
/// </summary>
public class JWSSigner
{
    /// <summary>
    /// A constructor without a private key, used for verification
    /// </summary>
    public JWSSigner();

    /// <summary>
    /// A constructiror with an private key - RSA or ECDSA, used for signing
    /// </summary>
    /// <param name="signer">The private key</param>
    /// <exception cref="ArgumentException">Invalid private key type</exception>
    public JWSSigner(AsymmetricAlgorithm signer);

    /// <summary>
    /// A constructiror with an private key - RSA or ECDSA, used for signing and hash algorithm
    /// </summary>
    /// <param name="signer">The private key</param>
    /// <param name="hashAlgorithm">Hash algorithm, mainly for RSA</param>
    /// <param name="useRSAPSS">In case of RSA, whether to use RSA-PSS</param>
    /// <exception cref="ArgumentException">Invalid private key type</exception>
    public JWSSigner(AsymmetricAlgorithm signer, HashAlgorithmName hashAlgorithm, bool useRSAPSS = false);

    /// <summary>
    /// A constructiror with an private key - HMAC, used for signing
    /// </summary>
    /// <param name="signer">The private key</param>
    /// <exception cref="ArgumentException">Invalid private key type</exception>
    public JWSSigner(HMAC signer);

    /// <summary>
    /// Clear some data.
    /// Every thing except the signer and the HashAlgorithmName!
    /// After calling 'Decode' and before calling 'Sign' next time you MUST call this method! 'Verify...' calls this method internally.
    /// </summary>
    public virtual void Clear();

    /// <summary>
    /// Change the signing key. This is useful for example when you want to sign with a new key.
    /// When you want to add a new signature, you set it with this method and then can use 'Sign' method to actually sign with
    /// the newly stetted key.
    /// </summary>
    /// <param name="signer">The private key</param>
    /// <param name="hashAlgorithm">Hash algorithm, mainly for RSA</param>
    /// <param name="useRSAPSS">In case of RSA, whether to use RSA-PSS</param>
    /// <exception cref="ArgumentException">Invalid private key type</exception>
    public void SetNewSigningKey(AsymmetricAlgorithm signer, HashAlgorithmName? hashAlgorithm = null, bool useRSAPSS = false);

    /// <summary>
    /// Change the signing key. This is useful for example when you want to sign with a new key.
    /// When you want to add a new signature, you set it with this method and then can use 'Sign' method to actually sign with
    /// the newly stetted key.
    /// </summary>
    /// <param name="signer">The private key</param>
    /// <exception cref="ArgumentException">Invalid private key type</exception>
    public void SetNewSigningKey(HMAC signer);

    /// <summary>
    /// Attach the signer's certificate to the JWS. ONLY public part of the certificate is used.
    /// This is optional and is only used to add the x5c, x5t header
    /// </summary>
    /// <param name="cert">The certificate</param>
    /// <param name="additionalCertificates">The additional certificates to add to the signature</param>
    public void AttachSignersCertificate(X509Certificate2 cert, IReadOnlyList<X509Certificate2>? additionalCertificates = null);

    /// <summary>
    /// Attach the onter signer's properties to the JWS. This is optional and is only used to add the jku, jwk, kid, x5u header.
    /// Can be tought as an alternative to 'AttachSignersCertificate' method.
    /// </summary>
    /// <param name="Jku">Optionally URL to download signer's JWK set</param>
    /// <param name="JwKey">Optionally some JWK key to use for signature verification</param>
    /// <param name="Kid">Optionally Key ID</param>
    /// <param name="X5u">Optionally URL to download signer's certificate.</param>
    public void AttachSignersOthersProperties(string? Jku = null, Jwk? JwKey = null, string? Kid = null, string? X5u = null);

    /// <summary>
    /// Digitally sign the payload and protected header.
    /// You may call this method multiple times to add multiple signatures, BEFORE calling 'Encode'.
    /// If you put multiple signatures, you'd better set a new signing key before calling this method,
    /// by calling method 'SetNewSigningKey'.
    /// </summary>
    /// <param name="payload">The payload</param>
    /// <param name="mimeType">Optionally the mime type of the payload, to put in the header</param>
    /// <param name="typHeaderparameter">Optionally the 'typ' header parameter https://www.rfc-editor.org/rfc/rfc7515#section-4.1.9,
    /// to put in the header.
    /// </param>
    /// <param name="b64">Wheter to use an Unencoded Payload Option - https://www.rfc-editor.org/rfc/rfc7797.
    /// By defult it is not used, so the payload is encoded. 
    /// If you want to use it, set it to FALSE. And use it carefully and with understanding.
    /// </param>
    public void Sign(ReadOnlySpan<byte> payload, string? mimeType = null, string? typHeaderparameter = null, bool? b64 = null);

    /// <summary>
    /// Verify the JWS
    /// </summary>
    /// <typeparam name="T">Must be JWSHeader or descendant from the JWSHeader record. Shall hold data about protected headers of the JWS.
    /// For example, it may be ETSI JWS header, or some other header, which is used in the JWS.
    /// </typeparam>
    /// <param name="keys">Public (RSA, ECDS) keys or Symmetric key (HMAC) to use for verification. MUST correspond to each of the JWS headers in the JWS,
    /// returned by te Decode method!
    /// MUST be descendent type from AsymmetricAlgorithm or HMAC</param>
    /// <param name="resolutor">Resolutor if "Cryt" header parameter if it EXISTS in any of the JWS headers in the JWS, returned by te Decode method!
    /// Please provide DECENT resolutor, as this is a SECURITY issue! You may read https://www.rfc-editor.org/rfc/rfc7515#section-4.1.10 for more information.
    /// You may also have a look at the ETSISigner class in the current project, for an example of a resolutor.
    /// IMPORTANT: If the "Cryt" header parameter is not present in any of the JWS headers in the JWS, returned by te Decode method - the resolutor is NOT called!
    /// So you may provide null as the resolutor, as you do not need it.
    /// </param>
    /// <returns>True / false = valid / invalid signature check</returns>
    /// <exception cref="ArgumentException">Some issues exists with the arguments and/or keys provided to this method</exception>
    public bool Verify<T>(IReadOnlyList<object> keys, Func<T, bool>? resolutor = null) where T : JWSHeader;

    /// <summary>
    /// Encode JWS 
    /// </summary>
    /// <param name="type">Type of JWS encoding. Default is Compact.
    /// NB. If there is more than 1 (one) signature, the result is always FULL!</param>
    /// <returns>The encoded JWS</returns>
    /// <exception cref="ArgumentException">Unknow enoding type</exception>
    public string Encode(JWSEncodeTypeEnum type = JWSEncodeTypeEnum.Compact);

    /// <summary>
    /// Decode JOSE's JWS
    /// </summary>
    /// <typeparam name="T">Must be descendant from the JWSHeader record. Shall hold data about protected headers of the JWS.
    /// For example, it may be ETSI JWS header, or some other header, which is used in the JWS.
    /// </typeparam>
    /// <param name="signature">The JWS</param>
    /// <param name="payload">The payload in JWS</param>
    /// <returns>A collection of JWS headers. Generally will be one, unless JWS is signed by multiple signer.
    /// If signed by multiple signers will return more then one header - for each signer</returns>
    public ReadOnlyCollection<T> Decode<T>(ReadOnlySpan<char> signature, out byte[] payload) where T : JWSHeader;

    /// <summary>
    /// Validates crytical header values, for an B64 signature
    /// </summary>
    /// <param name="header">The header</param>
    /// <returns>True - present and understood. Flase - other case</returns>
    public static bool B64Resolutor(JWSHeader header);

To use the class, you for example may follow these steps:

// Try get certificate
X509Certificate2? cert = GetCertificate(CertType.RSA);
if (cert == null) {
    Assert.Fail("NO RSA certificate available");
}

// Get RSA private key
RSA? rsaKey = cert.GetRSAPrivateKey();

// Or use JWK approach
JwkRSA? jwk = JsonSerializer.Deserialize<Jwk>(JWK_PRIVATE_2, JwkConstants.jsonOptions) as JwkRSA;

// Get RSA private key in case of JWK approach
RSA? rsaKey = jwk.GetRSAPrivateKey();

// Check if we have a key
if (rsaKey != null) {
    // Create signer 
    JWSSigner signer = new JWSSigner(rsaKey, HashAlgorithmName.SHA512);

    // OPTIONALLY attach the certificate to the JWS
    signer.AttachSignersCertificate(cert);

    // Sign
    signer.Sign(Encoding.UTF8.GetBytes(message), "text/json");

    // Encode - produce JWS
    var jSign = signer.Encode();

    // Decode - get the 
    ReadOnlyCollection<JWSHeader> headers = signer.Decode<JWSHeader>(jSign, out byte[] _);
    
    // OPTIONALLY get certificate used to sign it
    var pubCertEnc = headers[0].X5c?.FirstOrDefault();
    var pubCert = new X509Certificate2(Convert.FromBase64String(pubCertEnc));

    // OR get the key used for verification by other means
    // Like from the header - jwk option or from a URL (jku, x5u options, or from somewhere else)

    // Verify
    signer.Verify<JWSHeader>(new AsymmetricAlgorithm[] { pubCert.GetRSAPublicKey()! }, null);
}

Basically you:

  1. Get the signing key and/or certificate (from somewhere)
  2. Create the JWSSigner instance
  3. Optionally - attach the certificate to the JWS
  4. Sign the payload
  5. Encode the JWS
  6. For VERIFICATION
    1. Decode the JWS
    2. Optionally - get the certificate used to sign it
    3. Verify the JWS

Multiple signers

One point that might not be obvious, at first glance, is that the class JWSSigner supports multiple signers, just as the JWS specification does.

To add multiple signatures, you need to call the SetNewSigningKey and Sign methods multiple times, before calling the Encode method!

NB For the full list of examples, please see the corresponding methods in class CryptoEx.Tests.TestETSI and check out the implementation code in the library itself for insights.

B64 header - Unencoded Payload Option

Another point of interest might be the so-called "Unencoded Payload Option" and related to it b64 JWS signed header parameter.

The Sign method has a optional parameter - b64. If you do set it to false the produced JWS signature will be in an Unencoded Payload. If you do not set the b64 parameter or set it to true the Sign method will produce JWS signature in common (normal), base64Url encoded format.

The Verify method processes the signature in a correct way - it's logic understands the b64 header parameter and tries to verify the signatures accordingly.

There are some test methods, for the b64 option. They are at the CryptoEx.Tests.Test_B64_JWS_And_ETSI class and you may look at them for example usage.

For background information (specification) on the b64 stuff, please have a look at: RFC 7797.