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

Feature/b64 header support #64

Merged
merged 13 commits into from
Dec 23, 2024
Merged
146 changes: 143 additions & 3 deletions CreativeCode.JWS.Tests/JwsTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.RegularExpressions;
using CreativeCode.JWK.KeyParts;
Expand All @@ -6,6 +8,7 @@
using Newtonsoft.Json.Linq;
using static CreativeCode.JWK.Base64Helper;
using static CreativeCode.JWS.JWS;
using static CreativeCode.JWK.KeyParts.KeyParameter;

namespace CreativeCode.JWS.Tests;

Expand Down Expand Up @@ -306,7 +309,7 @@ public void JwsWithAdditionalProtectedHeadersAndDetachedModeCanBeSerializedWithC
}";
var payloadJsonNormalized = Regex.Replace(payloadJson, @"\s+", string.Empty, RegexOptions.Compiled);
var payload = Encoding.UTF8.GetBytes(payloadJsonNormalized);
var additionalHeaders = new Dictionary<string, string>()
var additionalHeaders = new Dictionary<string, object>()
{
{"testKey", "testValue"},
{"testKey2", "testValue2"},
Expand Down Expand Up @@ -378,7 +381,7 @@ public void JwsWithAdditionalProtectedHeadersAndDetachedModeCanBeSerializedWithC
}";
var payloadJsonNormalized = Regex.Replace(payloadJson, @"\s+", string.Empty, RegexOptions.Compiled);
var payload = Encoding.UTF8.GetBytes(payloadJsonNormalized);
var additionalHeaders = new Dictionary<string, string>()
var additionalHeaders = new Dictionary<string, object>()
{
{"testKey", "testValue"},
{"testKey2", "testValue2"},
Expand Down Expand Up @@ -445,7 +448,7 @@ public void JwsWithAdditionalProtectedHeadersAndDetachedModeCanBeSerializedWithF
}";
var payloadJsonNormalized = Regex.Replace(payloadJson, @"\s+", string.Empty, RegexOptions.Compiled);
var payload = Encoding.UTF8.GetBytes(payloadJsonNormalized);
var additionalHeaders = new Dictionary<string, string>()
var additionalHeaders = new Dictionary<string, object>()
{
{"testKey", "testValue"},
{"testKey2", "testValue2"},
Expand Down Expand Up @@ -492,4 +495,141 @@ public void JwsWithAdditionalProtectedHeadersAndDetachedModeCanBeSerializedWithF
var signature = Encoding.UTF8.GetString(Base64urlDecode(parsedJwsFlattenedJson.GetValue("signature").ToString()));
signature.Length.Should().BePositive("A JWS signature should be present");
}

[Fact]
public void CompactJwsWithUnencodedPayloadCanBeSerialized()
{
var keyUse = PublicKeyUse.Signature;
var keyOperations = new HashSet<KeyOperation>(new[] {KeyOperation.ComputeDigitalSignature, KeyOperation.VerifyDigitalSignature});
var algorithm = Algorithm.ES256;
var jwk = new JWK.JWK(algorithm, keyUse, keyOperations);
var additionalHeaders = new Dictionary<string, object>()
{
{"b64", false}
};
var criticalHeaders = new List<string>()
{
"b64"
};

var joseHeader = new ProtectedJoseHeader(jwk, SerializationOption.JwsCompactSerialization, "application/json", additionalHeaders, criticalHeaders);
var payload = Encoding.UTF8.GetBytes("payload");

var jws = new JWS(new []{joseHeader}, payload, ContentMode.Detached);
jws.CalculateSignature();
var jwsCompactJson = jws.Export();

var parts = jwsCompactJson.Split(".");
parts.Count().Should().Be(3, "A JWS using compact serialization should consist of three parts");

var headerJson = Encoding.UTF8.GetString(Base64urlDecode(parts.First()));
headerJson.Length.Should().BePositive("A JWS protected header should be present");
var parsedProtectedHeader = JObject.Parse(headerJson);

parsedProtectedHeader.TryGetValue("alg", out var _).Should().BeTrue();
parsedProtectedHeader.TryGetValue("jwk", out var _).Should().BeTrue();
parsedProtectedHeader.TryGetValue("kid", out var _).Should().BeTrue();
parsedProtectedHeader.TryGetValue("typ", out var _).Should().BeTrue();
parsedProtectedHeader.TryGetValue("cty", out var _).Should().BeTrue();
parsedProtectedHeader.TryGetValue("b64", out var _).Should().BeTrue();
parsedProtectedHeader.TryGetValue("crit", out var _).Should().BeTrue();

parsedProtectedHeader.GetValue("alg").ToString().Should().Be("ES256");
parsedProtectedHeader.GetValue("jwk").Children().Count().Should().Be(8);
var parsedJwk = JObject.Parse(parsedProtectedHeader.GetValue("jwk").ToString());
parsedJwk.GetValue("kty").ToString().Should().Be(jwk.KeyType.Type);
parsedJwk.GetValue("use").ToString().Should().Be(jwk.PublicKeyUse.KeyUse);
parsedJwk.GetValue("alg").ToString().Should().Be(jwk.Algorithm.Name);
parsedJwk.GetValue("kid").ToString().Should().Be(jwk.KeyID);
parsedJwk.GetValue("crv").ToString().Should().Be(jwk.KeyParameters[KeyParameter.ECKeyParameterCRV]);
parsedJwk.GetValue("y").ToString().Should().Be(jwk.KeyParameters[KeyParameter.ECKeyParameterY]);
parsedJwk.GetValue("x").ToString().Should().Be(jwk.KeyParameters[KeyParameter.ECKeyParameterX]);
parsedJwk.GetValue("key_ops").Values<string>().Should().BeEquivalentTo(jwk.KeyOperations.Select(op => op.Operation));
parsedProtectedHeader.GetValue("kid").ToString().Should().Be(jwk.KeyID);
parsedProtectedHeader.GetValue("typ").ToString().Should().Be("JOSE");
parsedProtectedHeader.GetValue("cty").ToString().Should().Be("json");
parsedProtectedHeader.GetValue("b64").Value<bool>().Should().BeFalse();
parsedProtectedHeader.GetValue("crit").ToObject<IList<string>>().Should().BeEquivalentTo(new List<string>(){"b64"});

var payloadFromJws = parts.ElementAt(1);
payloadFromJws.Length.Should().Be(0, "Payload should be empty due to detached content mode");

var signature = parts.Last();
signature.Length.Should().BePositive("A JWS signature should be present");

var publicKey = new JWK.JWK(jwk.Export());
VerifySignature(publicKey, SigningInput(joseHeader, payload), Base64urlDecode(signature)).Should().BeTrue();
}

[Fact]
public void JwsWithUnencodedPayloadWithCompleteSerializationThrowsException()
{
var keyUse = PublicKeyUse.Signature;
var keyOperations = new HashSet<KeyOperation>(new[] {KeyOperation.ComputeDigitalSignature, KeyOperation.VerifyDigitalSignature});
var algorithm = Algorithm.ES256;
var jwk = new JWK.JWK(algorithm, keyUse, keyOperations);
var additionalHeaders = new Dictionary<string, object>()
{
{"b64", false}
};
var criticalHeaders = new List<string>()
{
"b64"
};

var joseHeader = new ProtectedJoseHeader(jwk, SerializationOption.JwsCompactSerialization, "application/json", additionalHeaders, criticalHeaders);
var payload = Encoding.UTF8.GetBytes("payload");

Assert.Throws<ArgumentException>(() => new JWS(new []{joseHeader}, payload, ContentMode.Complete));
}

[Fact]
public void JwsWithDuplicateCriticalHeadersThrowsException()
{
var keyUse = PublicKeyUse.Signature;
var keyOperations = new HashSet<KeyOperation>(new[] {KeyOperation.ComputeDigitalSignature, KeyOperation.VerifyDigitalSignature});
var algorithm = Algorithm.ES256;
var jwk = new JWK.JWK(algorithm, keyUse, keyOperations);
var additionalHeaders = new Dictionary<string, object>()
{
{"b64", false}
};
var criticalHeaders = new List<string>()
{
"b64",
"b64"
};

Assert.Throws<ArgumentException>(() => new ProtectedJoseHeader(jwk, SerializationOption.JwsCompactSerialization, "application/json", additionalHeaders, criticalHeaders));
}

[Fact]
public void JwsWithCriticalHeadersAndEmptyAdditionalHeadersThrowsException()
{
var keyUse = PublicKeyUse.Signature;
var keyOperations = new HashSet<KeyOperation>(new[] {KeyOperation.ComputeDigitalSignature, KeyOperation.VerifyDigitalSignature});
var algorithm = Algorithm.ES256;
var jwk = new JWK.JWK(algorithm, keyUse, keyOperations);
var criticalHeaders = new List<string>()
{
"b64"
};

Assert.Throws<ArgumentException>(() => new ProtectedJoseHeader(jwk, SerializationOption.JwsCompactSerialization, "application/json", null, criticalHeaders));
}

[Fact]
public void JwsWithRFC7515HeadersAsCriticalHeadersThrowsException()
{
var keyUse = PublicKeyUse.Signature;
var keyOperations = new HashSet<KeyOperation>(new[] {KeyOperation.ComputeDigitalSignature, KeyOperation.VerifyDigitalSignature});
var algorithm = Algorithm.ES256;
var jwk = new JWK.JWK(algorithm, keyUse, keyOperations);
var criticalHeaders = new List<string>()
{
"kid"
};

Assert.Throws<ArgumentException>(() => new ProtectedJoseHeader(jwk, SerializationOption.JwsCompactSerialization, "application/json", null, criticalHeaders));
}
}
21 changes: 18 additions & 3 deletions JWS/JWS.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ public JWS(IEnumerable<ProtectedJoseHeader> protectedJoseHeaders, byte[] jwsPayl
throw new ArgumentException("At least one protected JoseHeader MUST be provided");
if (jwsPayload.Length == 0)
throw new ArgumentException("jwsPayload MUST NOT be empty");
if(protectedJoseHeaders.Any(protectedJoseHeader => protectedJoseHeader.AdditionalHeaders is not null && protectedJoseHeader.AdditionalHeaders.Any(h => h.Key.Equals("b64"))) && contentMode != ContentMode.Detached)
throw new ArgumentException("Unencoded Payload option ('b64' header) can only be used in combination with a detached content mode to avoid unsafe serialization of the JWS");

ProtectedJoseHeaders = protectedJoseHeaders;
JwsPayload = jwsPayload;
Expand Down Expand Up @@ -68,14 +70,27 @@ public static bool VerifySignature(JWK.JWK jwk, byte[] data, byte[] signature)
case "OCT":
return VerifyHmacSignature(jwk, data, signature);
default:
throw new InvalidOperationException("");
throw new InvalidOperationException($"Unknown JWK key type: '{jwk.KeyType.Type}'");
}
}

internal static byte[] SigningInput(ProtectedJoseHeader protectedJoseHeader, byte[] payload)
{
var protectedJoseHeaderJson = new ProtectedJoseHeaderConverter().Serialize(protectedJoseHeader);
return Encoding.ASCII.GetBytes(Base64urlEncode(Encoding.UTF8.GetBytes(protectedJoseHeaderJson)) + "." + Base64urlEncode(payload));
if (protectedJoseHeader.AdditionalHeaders is not null &&
!protectedJoseHeader.AdditionalHeaders.Any(h => h.Key.Equals("b64"))) // Regular signing input
{
return Encoding.ASCII.GetBytes(Base64urlEncode(Encoding.UTF8.GetBytes(protectedJoseHeaderJson)) + "." +
Base64urlEncode(payload));
}

// Unencoded signing input
var headerBytes =
Encoding.ASCII.GetBytes(Base64urlEncode(Encoding.UTF8.GetBytes(protectedJoseHeaderJson)) + ".");
var signingInput = new byte[headerBytes.Length + payload.Length];
Buffer.BlockCopy(headerBytes, 0, signingInput, 0, headerBytes.Length);
Buffer.BlockCopy(payload, 0, signingInput, headerBytes.Length, payload.Length);
return signingInput;
}

#endregion Signatures
Expand Down
43 changes: 40 additions & 3 deletions JWS/ProtectedJoseHeader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ Protected Header and the JWS Unprotected Header values that are

public class ProtectedJoseHeader
{
private static IReadOnlyList<string> _jsonPropertyNamesDefinedByJws = new[]
{"alg", "jwk", "kid", "typ", "cty", "crit", "x5t#S256", "x5t", "x5c", "x5u", "jku"};

[JsonProperty(PropertyName = "alg")]
[JWSConverterAttribute(typeof(AlgorithmConverter))]
public Algorithm Algorithm { get; internal set; } // REQUIRED, must match the 'alg' value of the supplied JWK
Expand All @@ -50,7 +53,11 @@ public class ProtectedJoseHeader

[JsonProperty()]
[JWSConverterAttribute(typeof(AdditionalHeadersConverter))]
public IReadOnlyDictionary<string, string> AdditionalHeaders { get; internal set; } // OPTIONAL
public IReadOnlyDictionary<string, object> AdditionalHeaders { get; internal set; } // OPTIONAL

[JsonProperty("crit")]
[JWSConverterAttribute(typeof(CriticalHeadersConverter))]
public IReadOnlyList<string> CriticalHeaders { get; internal set; } // OPTIONAL

public ProtectedJoseHeader(JWK.JWK jwk, string contentType, SerializationOption serializationOption)
{
Expand All @@ -66,8 +73,10 @@ public ProtectedJoseHeader(JWK.JWK jwk, string contentType, SerializationOption
Type = serializationOption;
ContentType = ShortenContentType(contentType);
}

public ProtectedJoseHeader(JWK.JWK jwk, SerializationOption serializationOption, string contentType = null, IReadOnlyDictionary<string, string> additionalHeaders = null)

public ProtectedJoseHeader(JWK.JWK jwk, SerializationOption serializationOption, string contentType = null,
IReadOnlyDictionary<string, object> additionalHeaders = null,
IReadOnlyList<string> criticalHeaders = null)
{
if (jwk is null)
throw new ArgumentNullException("jwk MUST be provided");
Expand All @@ -78,6 +87,34 @@ public ProtectedJoseHeader(JWK.JWK jwk, SerializationOption serializationOption,
Type = serializationOption;
ContentType = ShortenContentType(contentType);
AdditionalHeaders = additionalHeaders;

CheckCriticalHeaders(criticalHeaders, additionalHeaders);
CriticalHeaders = criticalHeaders;
}

/*
Producers
MUST NOT include Header Parameter names defined by this specification
or [JWA] for use with JWS, duplicate names, or names that do not
occur as Header Parameter names within the JOSE Header in the "crit"
list.

See RFC7515 - Section 4.1.11. "crit" (Critical) Header Parameter
*/

private void CheckCriticalHeaders(IReadOnlyList<string> criticalHeaders, IReadOnlyDictionary<string, object> additionalHeaders)
{
if (criticalHeaders is null)
return;

if (criticalHeaders.Count != criticalHeaders.Distinct().Count())
throw new ArgumentException("Values provided to be used within the 'crit' header MUST be unique");

if(_jsonPropertyNamesDefinedByJws.Intersect(criticalHeaders).Any())
throw new ArgumentException("Values provided to be used within the 'crit' header MUST not overlap with any header name defined in RFC7515");

if (additionalHeaders is null || !criticalHeaders.All(additionalHeaders.ContainsKey))
throw new ArgumentException("Values provided to be used within the 'crit' header MUST also be listed as additional headers");
}

/*
Expand Down
2 changes: 1 addition & 1 deletion JWS/TypeConverters/AdditionalProtectedHeadersConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public string Serialize(object propertyValue = null)
var sw = new StringWriter(sb);
var writer = new JsonTextWriter(sw);

var additionalHeaders = propertyValue as IReadOnlyDictionary<string, string>;
var additionalHeaders = propertyValue as IReadOnlyDictionary<string, object>;
foreach (var header in additionalHeaders!)
{
writer.WritePropertyName(header.Key);
Expand Down
38 changes: 38 additions & 0 deletions JWS/TypeConverters/CriticalHeadersConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

namespace CreativeCode.JWS.TypeConverters;

public class CriticalHeadersConverter : IJWSConverter
{
public string Serialize(object propertyValue = null)
{
if (propertyValue == null)
return string.Empty;

var sb = new StringBuilder();
var sw = new StringWriter(sb);
var writer = new JsonTextWriter(sw);
var criticalHeaders = propertyValue as IReadOnlyList<string>;
writer.WritePropertyName("crit");
writer.WriteStartArray();
foreach (var criticalHeader in criticalHeaders)
writer.WriteValue(criticalHeader);
writer.WriteEndArray();

return sb.ToString();
}

public object Deserialize(JToken jwkRepresentation)
{
throw new System.NotImplementedException();
}

public object Deserialize(JObject jwkRepresentation)
{
throw new System.NotImplementedException();
}
}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ The following configuration has been succesfully tested for building and running
### Project TODOs
- [] Complete support for all JWK key types
- [] Support for JWS Unprotected Header values
- [] Support for jku, x5u, x5c, x5t, x5t#S256, crit parameters in a protected JoseHeader
- [] Support for jku, x5u, x5c, x5t, x5t#S256 parameters in a protected JoseHeader
- [] Do not serialize empty "jwk" object in JWS for "oct" keys

### Documentation
Expand Down
Loading