From 1f41c7af12251533084ca4e507a07f67910f9cbc Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Wed, 8 Oct 2025 10:11:57 +0200 Subject: [PATCH] Moved to JsonSignature library Code that was previously embedded in th Duplicati.Library.Utility is now in its own library. --- .../Duplicati.Library.AutoUpdater.csproj | 1 + .../Library/AutoUpdater/UpdaterManager.cs | 1 + Duplicati/Library/Utility/CombinedStream.cs | 127 ------ Duplicati/Library/Utility/JSONSignature.cs | 363 ------------------ Duplicati/UnitTest/JSONSignatureTest.cs | 208 ---------- 5 files changed, 2 insertions(+), 698 deletions(-) delete mode 100644 Duplicati/Library/Utility/CombinedStream.cs delete mode 100644 Duplicati/Library/Utility/JSONSignature.cs delete mode 100644 Duplicati/UnitTest/JSONSignatureTest.cs diff --git a/Duplicati/Library/AutoUpdater/Duplicati.Library.AutoUpdater.csproj b/Duplicati/Library/AutoUpdater/Duplicati.Library.AutoUpdater.csproj index 9bfcd9434..725531024 100644 --- a/Duplicati/Library/AutoUpdater/Duplicati.Library.AutoUpdater.csproj +++ b/Duplicati/Library/AutoUpdater/Duplicati.Library.AutoUpdater.csproj @@ -7,6 +7,7 @@ + diff --git a/Duplicati/Library/AutoUpdater/UpdaterManager.cs b/Duplicati/Library/AutoUpdater/UpdaterManager.cs index f282afd61..20468fafd 100644 --- a/Duplicati/Library/AutoUpdater/UpdaterManager.cs +++ b/Duplicati/Library/AutoUpdater/UpdaterManager.cs @@ -31,6 +31,7 @@ using System.Text.Json; using System.Net.Http; using System.Threading; using Duplicati.Library.Common.IO; +using JsonSignature; namespace Duplicati.Library.AutoUpdater { diff --git a/Duplicati/Library/Utility/CombinedStream.cs b/Duplicati/Library/Utility/CombinedStream.cs deleted file mode 100644 index b801049e1..000000000 --- a/Duplicati/Library/Utility/CombinedStream.cs +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -using System; -using System.Collections.Generic; -using System.IO; - -namespace Duplicati.Library.Utility; - -/// -/// A stream that combines multiple streams into one. -/// -public class CombinedStream : Stream -{ - /// - /// The streams to combine - /// - private readonly Queue _streams; - /// - /// Flag keeping track of whether to leave the streams open when the combined stream is closed - /// - private readonly bool _leaveOpen; - - /// - /// Creates a new combined stream - /// - /// The streams to combine - /// True to leave the streams open when the combined stream is closed - public CombinedStream(IEnumerable streams, bool leaveOpen = false) - { - _streams = new Queue(streams); - _leaveOpen = leaveOpen; - } - - /// - public override bool CanRead => true; - - /// - public override bool CanSeek => false; - - /// - public override bool CanWrite => false; - - /// - public override long Length => throw new NotSupportedException(); - - /// - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - /// - public override void Flush() - { - } - - /// - public override int Read(byte[] buffer, int offset, int count) - { - if (_streams.Count == 0) - return 0; - - var bytesRead = _streams.Peek().Read(buffer, offset, count); - - if (bytesRead == 0) - { - var prev = _streams.Dequeue(); - if (!_leaveOpen) - prev.Dispose(); - - return Read(buffer, offset, count); - } - - return bytesRead; - } - - /// - public override long Seek(long offset, SeekOrigin origin) - { - throw new NotSupportedException(); - } - - /// - public override void SetLength(long value) - { - throw new NotSupportedException(); - } - - /// - public override void Write(byte[] buffer, int offset, int count) - { - throw new NotSupportedException(); - } - - /// - protected override void Dispose(bool disposing) - { - if (!disposing) - return; - - if (!_leaveOpen) - foreach (var stream in _streams) - stream.Dispose(); - - _streams.Clear(); - } -} diff --git a/Duplicati/Library/Utility/JSONSignature.cs b/Duplicati/Library/Utility/JSONSignature.cs deleted file mode 100644 index ed6f97f2f..000000000 --- a/Duplicati/Library/Utility/JSONSignature.cs +++ /dev/null @@ -1,363 +0,0 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -#nullable enable - -using System; -using System.Buffers.Binary; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Threading.Tasks; - -namespace Duplicati.Library.Utility; - -/// -/// Implementation of the SIGJSON signature format -/// -public static class JSONSignature -{ - /// - /// The RSA SHA256 signature method with PKCS1 padding - /// - public const string RSA_SHA256 = "RS256"; - /// - /// The RSA SHA384 signature method with PKCS1 padding - /// - public const string RSA_SHA384 = "RS384"; - /// - /// The RSA SHA512 signature method with PKCS1 padding - /// - public const string RSA_SHA512 = "RS512"; - - /// - /// Mapping of the headers to the version number - /// - private static readonly IReadOnlyDictionary SIGJSONHeaders = new Dictionary - { - {Convert.ToBase64String("//SIGJSONv1: "u8), 1}, - {Convert.ToBase64String("//SIGJSONv2: "u8), 2}, - {Convert.ToBase64String("//SIGJSONv3: "u8), 3}, - {Convert.ToBase64String("//SIGJSONv4: "u8), 4}, - {Convert.ToBase64String("//SIGJSONv5: "u8), 5}, - {Convert.ToBase64String("//SIGJSONv6: "u8), 6}, - {Convert.ToBase64String("//SIGJSONv7: "u8), 7}, - {Convert.ToBase64String("//SIGJSONv8: "u8), 8}, - {Convert.ToBase64String("//SIGJSONv9: "u8), 9} - }; - - /// - /// The header type for the signature - /// - private const string HEADER_TYPE = "SIGJSONv1"; - - /// - /// The length of the signature header string - /// - private static readonly int SignatureLength = "//SIGJSONv1: ".Length; - - /// - /// The built-in signing methods - /// - private static readonly IReadOnlyDictionary> _builtInSigningMethods = new Dictionary> - { - [RSA_SHA256] = (stream, key) => SignRSA(stream, key.PrivateKey, HashAlgorithmName.SHA256), - [RSA_SHA384] = (stream, key) => SignRSA(stream, key.PrivateKey, HashAlgorithmName.SHA384), - [RSA_SHA512] = (stream, key) => SignRSA(stream, key.PrivateKey, HashAlgorithmName.SHA512) - }; - - /// - /// The built-in verification methods - /// - private static readonly IReadOnlyDictionary> _builtInVerificationMethods = new Dictionary> - { - [RSA_SHA256] = (stream, key, signature) => VerifyRSA(stream, key.PublicKey, signature, HashAlgorithmName.SHA256), - [RSA_SHA384] = (stream, key, signature) => VerifyRSA(stream, key.PublicKey, signature, HashAlgorithmName.SHA384), - [RSA_SHA512] = (stream, key, signature) => VerifyRSA(stream, key.PublicKey, signature, HashAlgorithmName.SHA512) - }; - - /// - /// Signs a stream with the specified method - /// - /// The algorithm to use - /// The public key to validate with - /// The private key, if signing - /// The additional header values, if any - /// A custom method to sign with; Use null for the built-in methods - public record SignOperation(string Algorithm, string PublicKey, string PrivateKey, IEnumerable>? HeaderValues = null, Func? SignMethod = null); - - /// - /// Verifies a stream with the specified method - /// - /// The algorithm to use - /// The key to verify with - /// A custom method to verify with; Use null for the built-in methods - public record VerifyOperation(string Algorithm, string PublicKey, Func? VerifyMethod = null); - - /// - /// The result of a verification with a match - /// - /// The algorithm matched - /// The public key matched - /// The header values matched - public record VerifyMatch(string Algorithm, string PublicKey, IEnumerable> HeaderValues); - - /// - /// Signs a stream with SIGJSONv1 for multiple keys - /// - /// The stream of data to sign; must be seekable - /// The target stream where signed data is written - /// The key setups to sign with - /// An awaitable task - public static async Task SignAsync(Stream source, Stream target, IEnumerable signOperations) - { - if (signOperations == null || !signOperations.Any()) - throw new InvalidOperationException("No sign operations provided, cannot sign data"); - - source.Position = 0; - var buffer = new byte["//SIGJSON".Length]; - await source.ReadAsync(buffer, 0, buffer.Length); - if (Encoding.UTF8.GetString(buffer) == "//SIGJSON") - throw new Exception("Cannot sign data with an existing signature"); - - foreach (var key in signOperations) - { - source.Position = 0; - await target.WriteAsync(Encoding.UTF8.GetBytes(CreateSignature(source, key))); - } - - - source.Position = 0; - await source.CopyToAsync(target); - await target.FlushAsync(); - } - - /// - /// Validates a stream with SIGJSONv1 for at least one key match - /// - /// The stream of data to validate; must be seekable - /// The verification operations to use - /// true if at least one match is found; false otherwise - /// The stream is reset to the content position after validation - public static bool VerifyAtLeastOne(Stream source, IEnumerable verifyOperations) - => Verify(source, verifyOperations).Any(); - - /// - /// Validates a stream with SIGJSONv1 for multiple keys - /// - /// The stream of data to validate; must be seekable - /// The verification operations to use - /// A list of matches - /// The stream is reset to the content position after validation - public static IEnumerable Verify(Stream source, IEnumerable verifyOperations) - { - if (verifyOperations == null || !verifyOperations.Any()) - throw new InvalidOperationException("No verify operations provided, cannot verify data"); - - var buffer = new byte[8 * 1024]; - var headerLines = new List<(byte[] Header, byte[] Signature)>(); - - while (true) - { - // Record the start, so we can return to it if the line is not a signature - var position = source.Position; - - // Get the buffer as a span - var line = buffer.AsSpan(0, source.Read(buffer)); - // Find the line delimiter - var newlineIndex = Math.Max(0, line.IndexOf((byte)'\n')); - // Get the part of the buffer that contains the line - line = line[..newlineIndex]; - - // Check if the signature matches a known version - var version = 0; - if (line.Length > SignatureLength) - SIGJSONHeaders.TryGetValue(Convert.ToBase64String(line[..SignatureLength]), out version); - - // No matching header found, reset stream to include the comment line and stop searching - if (version < 1) - { - source.Position = position; - break; - } - - // Valid signature line, set the stream to exclude the comment line - source.Position = position + newlineIndex + 1; - - // We only parse the v1 lines for now, and ignore the others - if (version == 1) - { - // This can throw on non-UTF data, non-base64 data, etc - try - { - var kp = Encoding.UTF8.GetString(line[SignatureLength..]).Split('.', StringSplitOptions.TrimEntries); - if (kp.Length == 2) - headerLines.Add(( - Header: Convert.FromBase64String(kp[0]), - Signature: Convert.FromBase64String(kp[1]) - )); - } - catch - { - // Treat invalid lines as not a signature - source.Position = position; - break; - } - } - } - - // No more header lines, so we are at the start of the content - var startOfContent = source.Position; - var result = new List(); - - // For each header line, validate the signature - foreach (var (header, signature) in headerLines) - { - source.Position = startOfContent; - result.AddRange(ValidateSignature(source, header, verifyOperations, signature)); - } - - // Reset the stream to the start of the content - source.Position = startOfContent; - return result; - } - - /// - /// Creates a single signature line - /// - /// The data to sign - /// The sign operation data - /// The signature string - private static string CreateSignature(Stream data, SignOperation skey) - { - var headers = skey.HeaderValues ?? new List>(); - headers = headers.Append(new KeyValuePair("alg", skey.Algorithm)); - headers = headers.Append(new KeyValuePair("key", skey.PublicKey)); - headers = headers.Append(new KeyValuePair("typ", HEADER_TYPE)); - - if (headers.DistinctBy(x => x.Key).Count() != headers.Count()) - throw new InvalidOperationException("Duplicate headers are not allowed"); - - var headerData = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(headers, new System.Text.Json.JsonSerializerOptions { WriteIndented = false }); - var signMethod = skey.SignMethod ?? _builtInSigningMethods[skey.Algorithm]; - if (signMethod == null) - throw new InvalidOperationException($"No signing method found for: {skey.Algorithm}"); - - var intBuf = new byte[4]; - BinaryPrimitives.WriteUInt32BigEndian(intBuf, (uint)headerData.Length); - - using var headerStream = new MemoryStream(); - headerStream.Write(intBuf); - headerStream.Write(headerData); - - headerStream.Position = 0; - using var prefixedStream = new CombinedStream([headerStream, data], leaveOpen: true); - var signature = signMethod(prefixedStream, skey); - - var sigJsonV1 = new StringBuilder(); - sigJsonV1.Append("//SIGJSONv1: "); - sigJsonV1.Append(Convert.ToBase64String(headerData)); - sigJsonV1.Append("."); - sigJsonV1.Append(Convert.ToBase64String(signature)); - sigJsonV1.Append("\n"); - - return sigJsonV1.ToString(); - } - - /// - /// Verifies a single signature line - /// - /// The payload data - /// The header data - /// The verification operations possible - /// The signature to match - /// A list of matches - private static IEnumerable ValidateSignature(Stream data, byte[] header, IEnumerable verifiers, byte[] signature) - { - var startOfContent = data.Position; - - List>? headers = null; - try { headers = System.Text.Json.JsonSerializer.Deserialize>>(header); } - catch { } - - var alg = headers?.FirstOrDefault(x => x.Key == "alg").Value; - var key = headers?.FirstOrDefault(x => x.Key == "key").Value; - var typ = headers?.FirstOrDefault(x => x.Key == "typ").Value; - if (string.IsNullOrWhiteSpace(alg) || string.IsNullOrWhiteSpace(key) || typ != HEADER_TYPE) - yield break; - - var potentialMatches = verifiers.Where(x => x.Algorithm == alg && x.PublicKey == key); - if (!potentialMatches.Any()) - yield break; - - var intBuf = new byte[4]; - BinaryPrimitives.WriteUInt32BigEndian(intBuf, (uint)header.Length); - - using var headerStream = new MemoryStream(); - headerStream.Write(intBuf); - headerStream.Write(header); - - foreach (var verifier in potentialMatches) - { - var verifyMethod = verifier.VerifyMethod ?? _builtInVerificationMethods[verifier.Algorithm]; - if (verifyMethod == null) - throw new InvalidOperationException($"No verification method found for: {verifier.Algorithm}"); - - headerStream.Position = 0; - data.Position = startOfContent; - - using var prefixedStream = new CombinedStream([headerStream, data], leaveOpen: true); - if (verifyMethod(prefixedStream, verifier, signature)) - yield return new VerifyMatch(alg, key, headers!); - } - } - - /// - /// Signs a stream with RSA and the specified hash algorithm - /// - /// The data to sign - /// The key to sign with - /// The hash algorithm to use - /// The signature - private static byte[] SignRSA(Stream data, string privateKey, HashAlgorithmName hashAlgorithmName) - { - using var rsa = new RSACryptoServiceProvider(); - rsa.FromXmlString(privateKey); - return rsa.SignData(data, hashAlgorithmName, RSASignaturePadding.Pkcs1); - } - - /// - /// Verifies a stream signed with RSA and the specified hash algorithm - /// - /// The data to verify - /// The key to verify with - /// The signature to use - /// The algorithm to use - /// true if the signature is valid; false otherwise - private static bool VerifyRSA(Stream data, string publicKey, byte[] signature, HashAlgorithmName hashAlgorithmName) - { - using var rsa = new RSACryptoServiceProvider(); - rsa.FromXmlString(publicKey); - return rsa.VerifyData(data, signature, hashAlgorithmName, RSASignaturePadding.Pkcs1); - } -} \ No newline at end of file diff --git a/Duplicati/UnitTest/JSONSignatureTest.cs b/Duplicati/UnitTest/JSONSignatureTest.cs deleted file mode 100644 index 7521a0415..000000000 --- a/Duplicati/UnitTest/JSONSignatureTest.cs +++ /dev/null @@ -1,208 +0,0 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using Duplicati.Library.Utility; -using NUnit.Framework; - -namespace Duplicati.UnitTest; - -[Category("SignatureTest")] -public class JSONSignatureTest -{ - [Test] - [TestCase(true)] - [TestCase(false)] - public void SignWithASingleKeyShouldWork(bool withHeaders) - { - var content = System.Text.Encoding.UTF8.GetBytes(System.Text.Json.JsonSerializer.Serialize(new { test = "test", extra = 234 })); - var headers = withHeaders ? new System.Collections.Generic.Dictionary { { "test", "1234" } } : null; - var key = new RSACryptoServiceProvider(2048); - - using var source = new MemoryStream(content); - using var target = new MemoryStream(); - - JSONSignature.SignAsync(source, target, [new JSONSignature.SignOperation(JSONSignature.RSA_SHA256, key.ToXmlString(false), key.ToXmlString(true), headers)]).Await(); - - target.Position = 0; - - var valids = JSONSignature.Verify(target, [new JSONSignature.VerifyOperation(JSONSignature.RSA_SHA256, key.ToXmlString(false))]); - Assert.That(valids, Has.Count.EqualTo(1)); - } - - [Test] - [TestCase(true)] - [TestCase(false)] - public void SignWithMultipleAlgsShouldWork(bool withHeaders) - { - var content = System.Text.Encoding.UTF8.GetBytes(System.Text.Json.JsonSerializer.Serialize(new { test = "test", extra = 234 })); - var headers = withHeaders ? new System.Collections.Generic.Dictionary { { "test", "1234" } } : null; - var key = new RSACryptoServiceProvider(2048); - - using var source = new MemoryStream(content); - using var target = new MemoryStream(); - - var algs = new[] { JSONSignature.RSA_SHA256, JSONSignature.RSA_SHA384, JSONSignature.RSA_SHA512 }; - - JSONSignature.SignAsync(source, target, algs.Select(x => new JSONSignature.SignOperation(x, key.ToXmlString(false), key.ToXmlString(true), headers))).Await(); - - target.Position = 0; - var valids = JSONSignature.Verify(target, algs.Select(x => new JSONSignature.VerifyOperation(x, key.ToXmlString(false)))); - Assert.That(valids, Has.Count.EqualTo(algs.Length)); - foreach (var alg in algs) - { - target.Position = 0; - var valid = JSONSignature.Verify(target, [new JSONSignature.VerifyOperation(alg, key.ToXmlString(false))]); - Assert.That(valid, Has.Count.EqualTo(1)); - Assert.That(valid.First().Algorithm, Is.EqualTo(alg)); - Assert.That(valid.First().PublicKey, Is.EqualTo(key.ToXmlString(false))); - - target.Position = 0; - var hasOne = JSONSignature.VerifyAtLeastOne(target, [new JSONSignature.VerifyOperation(alg, key.ToXmlString(false))]); - Assert.That(hasOne, Is.True); - } - } - - [Test] - [TestCase(true)] - [TestCase(false)] - public void SignWithMultipleKeysShouldWork(bool withHeaders) - { - var content = System.Text.Encoding.UTF8.GetBytes(System.Text.Json.JsonSerializer.Serialize(new { test = "test", extra = 234 })); - var headers = withHeaders ? new System.Collections.Generic.Dictionary { { "test", "1234" } } : null; - var keys = new[] { new RSACryptoServiceProvider(2048), new RSACryptoServiceProvider(2048), new RSACryptoServiceProvider(1024) }; - - using var source = new MemoryStream(content); - using var target = new MemoryStream(); - - var algs = new[] { JSONSignature.RSA_SHA256, JSONSignature.RSA_SHA384, JSONSignature.RSA_SHA512 }; - var algkeycombos = algs.SelectMany(alg => keys.Select(key => (Alg: alg, Key: key))).ToArray(); - - JSONSignature.SignAsync(source, target, algkeycombos.Select(x => new JSONSignature.SignOperation(x.Alg, x.Key.ToXmlString(false), x.Key.ToXmlString(true), headers))).Await(); - - target.Position = 0; - var valids = JSONSignature.Verify(target, algkeycombos.Select(x => new JSONSignature.VerifyOperation(x.Alg, x.Key.ToXmlString(false)))); - Assert.That(valids, Has.Count.EqualTo(algkeycombos.Length)); - foreach (var alg in algkeycombos) - { - target.Position = 0; - var valid = JSONSignature.Verify(target, [new JSONSignature.VerifyOperation(alg.Alg, alg.Key.ToXmlString(false))]); - Assert.That(valid, Has.Count.EqualTo(1)); - Assert.That(valid.First().Algorithm, Is.EqualTo(alg.Alg)); - Assert.That(valid.First().PublicKey, Is.EqualTo(alg.Key.ToXmlString(false))); - - target.Position = 0; - var hasOne = JSONSignature.VerifyAtLeastOne(target, [new JSONSignature.VerifyOperation(alg.Alg, alg.Key.ToXmlString(false))]); - Assert.That(hasOne, Is.True); - } - } - - [Test] - public void InvalidSignaturesShouldNotThrow() - { - var broken1 = new[] { - // Invalid Base64 data - "//SIGJSONv1: ####.abc=\n{\"x\": 1}"u8.ToArray(), - - // Invalid Base64 data - "//SIGJSONv1: abc.abc\n{\"x\": 1}"u8.ToArray(), - - // Invalid header - "//SIGJSONv1:abc=.abc=\n{\"x\": 1}"u8.ToArray(), - - // No newline - "//SIGJSONv1: abc=.abc={\"x\": 1}"u8.ToArray(), - - // No newline - "//SIGJSONv1: abc=.abc={\"x\": 1}\n"u8.ToArray(), - - // Extra newline - "//SIGJSONv1: abc=.abc=\n{\"x\": 1}\n"u8.ToArray(), - }; - - var key = new RSACryptoServiceProvider(2048); - foreach (var c in broken1) - { - using var source = new MemoryStream(c); - using var target = new MemoryStream(); - - var valids = JSONSignature.Verify(target, [new JSONSignature.VerifyOperation(JSONSignature.RSA_SHA256, key.ToXmlString(false))]); - Assert.That(valids, Has.Count.EqualTo(0)); - } - } - - [Test] - [TestCase(true)] - [TestCase(false)] - public void TaintedDataShouldNotValidate(bool withHeaders) - { - var content = System.Text.Encoding.UTF8.GetBytes(System.Text.Json.JsonSerializer.Serialize(new { test = "test", extra = 234 })); - var headers = withHeaders ? new System.Collections.Generic.Dictionary { { "test", "1234" } } : null; - var keys = new[] { new RSACryptoServiceProvider(2048), new RSACryptoServiceProvider(2048), new RSACryptoServiceProvider(1024) }; - - using var source = new MemoryStream(content); - using var target = new MemoryStream(); - - var algs = new[] { JSONSignature.RSA_SHA256, JSONSignature.RSA_SHA384, JSONSignature.RSA_SHA512 }; - var algkeycombos = algs.SelectMany(alg => keys.Select(key => (Alg: alg, Key: key))).ToArray(); - - JSONSignature.SignAsync(source, target, algkeycombos.Select(x => new JSONSignature.SignOperation(x.Alg, x.Key.ToXmlString(false), x.Key.ToXmlString(true), headers))).Await(); - - // Taint the data - target.Position = target.Length - 1; - target.WriteByte((byte)'\n'); - - target.Position = 0; - var valids = JSONSignature.Verify(target, algkeycombos.Select(x => new JSONSignature.VerifyOperation(x.Alg, x.Key.ToXmlString(false)))); - Assert.That(valids, Has.Count.EqualTo(0)); - } - - [Test] - [TestCase(true, 1)] // Offset=1 breaks the JSON - [TestCase(false, 1)] - [TestCase(true, 4)] // Offset=4 changes the header key name - [TestCase(false, 4)] - public void TaintedHeadersShouldNotValidate(bool withHeaders, int offset) - { - var content = System.Text.Encoding.UTF8.GetBytes(System.Text.Json.JsonSerializer.Serialize(new { test = "test", extra = 234 })); - var headers = withHeaders ? new System.Collections.Generic.Dictionary { { "test", "1234" } } : null; - var keys = new[] { new RSACryptoServiceProvider(2048), new RSACryptoServiceProvider(2048), new RSACryptoServiceProvider(1024) }; - - using var source = new MemoryStream(content); - using var target = new MemoryStream(); - - var algs = new[] { JSONSignature.RSA_SHA256, JSONSignature.RSA_SHA384, JSONSignature.RSA_SHA512 }; - var algkeycombos = algs.SelectMany(alg => keys.Select(key => (Alg: alg, Key: key))).ToArray(); - - JSONSignature.SignAsync(source, target, algkeycombos.Select(x => new JSONSignature.SignOperation(x.Alg, x.Key.ToXmlString(false), x.Key.ToXmlString(true), headers))).Await(); - - // Taint the header data for the first signature - target.Position = "//SIGJSONv1: ".Length + offset; - var cur = target.ReadByte(); - target.Position -= 1; - target.WriteByte((byte)(cur - 1)); - - target.Position = 0; - var valids = JSONSignature.Verify(target, algkeycombos.Select(x => new JSONSignature.VerifyOperation(x.Alg, x.Key.ToXmlString(false)))); - Assert.That(valids, Has.Count.EqualTo(algkeycombos.Length - 1)); - } -}