// Copyright (C) 2024, 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); } }