Skip to content

Commit

Permalink
Add support for detached content mode
Browse files Browse the repository at this point in the history
  • Loading branch information
alexzautke committed Oct 4, 2024
1 parent 3a098ef commit 1bc9004
Show file tree
Hide file tree
Showing 5 changed files with 177 additions and 23 deletions.
150 changes: 143 additions & 7 deletions CreativeCode.JWS.Tests/JwsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ void export()
}

[Fact]
public void JwsWithAdditionalProtectedHeadersCanBeSerialized()
public void JwsWithAdditionalProtectedHeadersAndDetachedModeCanBeSerializedWithCompactSerialization()
{
var keyUse = PublicKeyUse.Signature;
var keyOperations = new HashSet<KeyOperation>(new[] {KeyOperation.ComputeDigitalSignature, KeyOperation.VerifyDigitalSignature});
Expand All @@ -308,11 +308,12 @@ public void JwsWithAdditionalProtectedHeadersCanBeSerialized()
var payload = Encoding.UTF8.GetBytes(payloadJsonNormalized);
var additionalHeaders = new Dictionary<string, string>()
{
{"testKey", "testValue"}
{"testKey", "testValue"},
{"testKey2", "testValue2"},
};

var joseHeader = new ProtectedJoseHeader(jwk, SerializationOption.JwsCompactSerialization, "application/json", additionalHeaders);
var jws = new JWS(new []{joseHeader}, payload);
var jws = new JWS(new []{joseHeader}, payload, ContentMode.Detached);
jws.CalculateSignature();
var jwsCompactJson = jws.Export();

Expand All @@ -329,6 +330,7 @@ public void JwsWithAdditionalProtectedHeadersCanBeSerialized()
parsedProtectedHeader.TryGetValue("typ", out var _).Should().BeTrue();
parsedProtectedHeader.TryGetValue("cty", out var _).Should().BeTrue();
parsedProtectedHeader.TryGetValue("testKey", out var _).Should().BeTrue();
parsedProtectedHeader.TryGetValue("testKey2", out var _).Should().BeTrue();

parsedProtectedHeader.GetValue("alg").ToString().Should().Be("ES256");
parsedProtectedHeader.GetValue("jwk").Children().Count().Should().Be(8);
Expand All @@ -345,15 +347,149 @@ public void JwsWithAdditionalProtectedHeadersCanBeSerialized()
parsedProtectedHeader.GetValue("typ").ToString().Should().Be("JOSE");
parsedProtectedHeader.GetValue("cty").ToString().Should().Be("json");
parsedProtectedHeader.GetValue("testKey").ToString().Should().Be("testValue");

var payloadFromJws = Encoding.UTF8.GetString(Base64urlDecode(parts.ElementAt(1)));
payloadFromJws.Length.Should().BePositive("A JWS payload should be present");
payloadFromJws.Should().Be(payloadJsonNormalized);
parsedProtectedHeader.GetValue("testKey2").ToString().Should().Be("testValue2");

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, Encoding.UTF8.GetBytes(payloadJsonNormalized)), Base64urlDecode(signature)).Should().BeTrue();
}

[Fact]
public void JwsWithAdditionalProtectedHeadersAndDetachedModeCanBeSerializedWithCompleteSerialization()
{
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 payloadJson = @"{
""key1"": ""test"",
""key2"": ""test2"",
""testArray"": [
{
""complexTest"": ""test"",
""success"": true
}
]
}";
var payloadJsonNormalized = Regex.Replace(payloadJson, @"\s+", string.Empty, RegexOptions.Compiled);
var payload = Encoding.UTF8.GetBytes(payloadJsonNormalized);
var additionalHeaders = new Dictionary<string, string>()
{
{"testKey", "testValue"},
{"testKey2", "testValue2"},
};

var joseHeader = new ProtectedJoseHeader(jwk, SerializationOption.JwsCompleteJsonSerialization, "application/json", additionalHeaders);
var jws = new JWS(new []{joseHeader}, payload, ContentMode.Detached);
jws.CalculateSignature();
var jwsCompleteJson = jws.Export();
var parsedJwsCompleteJson = JObject.Parse(jwsCompleteJson);

var payloadFromJws = parsedJwsCompleteJson.GetValue("payload");
payloadFromJws.Should().BeNull("Payload should be empty due to detached content mode");

var signatures = (JObject)parsedJwsCompleteJson.GetValue("signatures").First;

var headerJson = Encoding.UTF8.GetString(Base64urlDecode(signatures.GetValue("protected").ToString()));
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.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+JSON");
parsedProtectedHeader.GetValue("cty").ToString().Should().Be("json");
parsedProtectedHeader.GetValue("testKey").ToString().Should().Be("testValue");
parsedProtectedHeader.GetValue("testKey2").ToString().Should().Be("testValue2");

var signature = Encoding.UTF8.GetString(Base64urlDecode(signatures.GetValue("signature").ToString()));
signature.Length.Should().BePositive("A JWS signature should be present");
}

[Fact]
public void JwsWithAdditionalProtectedHeadersAndDetachedModeCanBeSerializedWithFlattenedSerialization()
{
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 payloadJson = @"{
""key1"": ""test"",
""key2"": ""test2"",
""testArray"": [
{
""complexTest"": ""test"",
""success"": true
}
]
}";
var payloadJsonNormalized = Regex.Replace(payloadJson, @"\s+", string.Empty, RegexOptions.Compiled);
var payload = Encoding.UTF8.GetBytes(payloadJsonNormalized);
var additionalHeaders = new Dictionary<string, string>()
{
{"testKey", "testValue"},
{"testKey2", "testValue2"},
};

var joseHeader = new ProtectedJoseHeader(jwk, SerializationOption.JwsFlattenedJsonSerialization, "application/json", additionalHeaders);
var jws = new JWS(new []{joseHeader}, payload, ContentMode.Detached);
jws.CalculateSignature();
var jwsFlattenedJson = jws.Export();
var parsedJwsFlattenedJson = JObject.Parse(jwsFlattenedJson);

var payloadFromJws = parsedJwsFlattenedJson.GetValue("payload");
payloadFromJws.Should().BeNull("Payload should be empty due to detached content mode");

var headerJson = Encoding.UTF8.GetString(Base64urlDecode(parsedJwsFlattenedJson.GetValue("protected").ToString()));
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("testKey", out var _).Should().BeTrue();
parsedProtectedHeader.TryGetValue("testKey2", 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+JSON");
parsedProtectedHeader.GetValue("cty").ToString().Should().Be("json");
parsedProtectedHeader.GetValue("testKey").ToString().Should().Be("testValue");
parsedProtectedHeader.GetValue("testKey2").ToString().Should().Be("testValue2");

var signature = Encoding.UTF8.GetString(Base64urlDecode(parsedJwsFlattenedJson.GetValue("signature").ToString()));
signature.Length.Should().BePositive("A JWS signature should be present");
}
}
5 changes: 4 additions & 1 deletion JWS/JWS.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ public class JWS
internal byte[] JwsPayload { get; } // Raw value, NOT base64 encoded
internal IEnumerable<byte[]> JwsSignatures { get; private set; } // Raw value, NOT base64 encoded

public JWS(IEnumerable<ProtectedJoseHeader> protectedJoseHeaders, byte[] jwsPayload)
internal ContentMode ContentMode { get; }

public JWS(IEnumerable<ProtectedJoseHeader> protectedJoseHeaders, byte[] jwsPayload, ContentMode contentMode = ContentMode.Complete)
{
if (protectedJoseHeaders is null)
throw new ArgumentNullException("protectedJoseHeaders MUST be provided");
Expand All @@ -32,6 +34,7 @@ public JWS(IEnumerable<ProtectedJoseHeader> protectedJoseHeaders, byte[] jwsPayl

ProtectedJoseHeaders = protectedJoseHeaders;
JwsPayload = jwsPayload;
ContentMode = contentMode;
}

#region Signatures
Expand Down
4 changes: 2 additions & 2 deletions JWS/ProtectedJoseHeader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,15 @@ public class ProtectedJoseHeader
[JsonProperty(PropertyName = "typ")]
[JWSConverterAttribute(typeof(SerializationOptionConverter))]
public SerializationOption Type { get; internal set; } // OPTIONAL

[JsonProperty(PropertyName = "cty")]
public string ContentType { get; internal set; } // OPTIONAL

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

public ProtectedJoseHeader(JWK.JWK jwk, string contentType, SerializationOption serializationOption)
public ProtectedJoseHeader(JWK.JWK jwk, string contentType, SerializationOption serializationOption, ContentMode contentMode = ContentMode.Complete)
{
if (jwk is null)
throw new ArgumentNullException("jwk MUST be provided");
Expand Down
6 changes: 6 additions & 0 deletions JWS/SerializationOption.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,10 @@ private SerializationOption(string name)
}

}

public enum ContentMode
{
Complete,
Detached
}
}
35 changes: 22 additions & 13 deletions JWS/TypeConverters/JwsConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ 3. Create the JSON object(s) containing the desired set of Header
var customConverterAttribute = joseHeadersProperty.CustomAttributes.FirstOrDefault(a => a.AttributeType == typeof(JWSConverterAttribute));
var customConverterType = customConverterAttribute.ConstructorArguments.FirstOrDefault(a => a.ArgumentType == typeof(Type)).Value;
var instance = Activator.CreateInstance(customConverterType as Type, true) as IJWSConverter;
var joseHeaders = joseHeadersProperty.GetValue(value) as IEnumerable<ProtectedJoseHeader>;
var joseHeaders = (joseHeadersProperty.GetValue(value) as IEnumerable<ProtectedJoseHeader>).ToList();

if (joseHeaders.Any(joseHeader => joseHeader.Type == SerializationOption.JwsCompactSerialization || joseHeader.Type == SerializationOption.JwsFlattenedJsonSerialization) && joseHeaders.Count() > 1)
throw new InvalidOperationException("Multiple headers/signatures are only supported using the General JWS JSON Serialization Syntax. At least one header specified a JWS Compact Serialization or Flattened JWS JSON Serialization Syntax.");
Expand Down Expand Up @@ -90,26 +90,32 @@ Section 7.2.
*/

if (jws.ProtectedJoseHeaders.All(protectedJoseHeader => protectedJoseHeader.Type == SerializationOption.JwsCompactSerialization))
CompactSerialization(writer, urlEncodedProtectedHeaders.First(), urlEncodedPayload, urlEncodedSignatures.First());
CompactSerialization(writer, urlEncodedProtectedHeaders.First(), urlEncodedPayload, urlEncodedSignatures.First(), jws.ContentMode);
else if (jws.ProtectedJoseHeaders.All(protectedJoseHeader => protectedJoseHeader.Type == SerializationOption.JwsFlattenedJsonSerialization))
FlattenedJsonSerialization(writer, urlEncodedPayload, urlEncodedProtectedHeaders.First(), urlEncodedSignatures.First());
FlattenedJsonSerialization(writer, urlEncodedPayload, urlEncodedProtectedHeaders.First(), urlEncodedSignatures.First(), jws.ContentMode);
else if (jws.ProtectedJoseHeaders.All(protectedJoseHeader => protectedJoseHeader.Type == SerializationOption.JwsCompleteJsonSerialization))
CompleteJsonSerialization(writer, urlEncodedPayload, urlEncodedProtectedHeaders, urlEncodedSignatures);
CompleteJsonSerialization(writer, urlEncodedPayload, urlEncodedProtectedHeaders, urlEncodedSignatures, jws.ContentMode);
else
throw new InvalidOperationException("JWS Protected headers indicated mixed serialization options. All headers MUST use the same option.");
}

private void CompactSerialization(JsonWriter writer, string urlEncodedProtectedHeader, string urlEncodedPayload, string urlEncodedSignature)
private void CompactSerialization(JsonWriter writer, string urlEncodedProtectedHeader, string urlEncodedPayload, string urlEncodedSignature, ContentMode contentMode)
{
writer.WriteRaw($"{urlEncodedProtectedHeader}.{urlEncodedPayload}.{urlEncodedSignature}");
if(contentMode == ContentMode.Complete)
writer.WriteRaw($"{urlEncodedProtectedHeader}.{urlEncodedPayload}.{urlEncodedSignature}");
else
writer.WriteRaw($"{urlEncodedProtectedHeader}..{urlEncodedSignature}");
}

private void FlattenedJsonSerialization(JsonWriter writer, string urlEncodedPayload, string urlEncodedProtectedHeader, string urlEncodedSignature)
private void FlattenedJsonSerialization(JsonWriter writer, string urlEncodedPayload, string urlEncodedProtectedHeader, string urlEncodedSignature, ContentMode contentMode)
{
writer.WriteStartObject();

writer.WritePropertyName("payload");
writer.WriteValue(urlEncodedPayload);
if (contentMode == ContentMode.Complete)
{
writer.WritePropertyName("payload");
writer.WriteValue(urlEncodedPayload);
}

writer.WritePropertyName("protected");
writer.WriteValue(urlEncodedProtectedHeader);
Expand All @@ -120,15 +126,18 @@ private void FlattenedJsonSerialization(JsonWriter writer, string urlEncodedPayl
writer.WriteEndObject();
}

private void CompleteJsonSerialization(JsonWriter writer, string urlEncodedPayload, IEnumerable<string> urlEncodedProtectedHeaders, IEnumerable<string> urlEncodedSignatures)
private void CompleteJsonSerialization(JsonWriter writer, string urlEncodedPayload, IEnumerable<string> urlEncodedProtectedHeaders, IEnumerable<string> urlEncodedSignatures, ContentMode contentMode)
{
if (urlEncodedProtectedHeaders.Count() != urlEncodedSignatures.Count())
throw new InvalidOperationException("Count of protected JoseHeaders does not match count of provided signatures.");

writer.WriteStartObject();

writer.WritePropertyName("payload");
writer.WriteValue(urlEncodedPayload);

if (contentMode == ContentMode.Complete)
{
writer.WritePropertyName("payload");
writer.WriteValue(urlEncodedPayload);
}

writer.WritePropertyName("signatures");
writer.WriteStartArray();
Expand Down

0 comments on commit 1bc9004

Please sign in to comment.