From de47666a548fffe4e8f00f418103d75316b1b9cc Mon Sep 17 00:00:00 2001 From: Nick Guerrera Date: Tue, 7 Jan 2025 17:21:36 -0600 Subject: [PATCH] Add C3ID implementation This ports C3ID computation (that we will need soon) from https://github.com/microsoft/security-utilities Changes: - Company name is a parameter and not hard-coded to Microsoft - Optimized like the rest of Cask to avoid allocations and write the result to a span Tests added here were also run against the implementation in security-utilities to check equivalence. Also: fix edge cases with empty span input in encoding polyfills. --- src/Cask/CrossCompanyCorrelatingId.cs | 96 +++++++++++++++++++ src/Cask/Polyfill.cs | 15 +++ .../CrossCompanyCorrelatingIdTests.cs | 40 ++++++++ src/Tests/Cask.Tests/PolyfillTests.cs | 25 +++++ 4 files changed, 176 insertions(+) create mode 100644 src/Cask/CrossCompanyCorrelatingId.cs create mode 100644 src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs diff --git a/src/Cask/CrossCompanyCorrelatingId.cs b/src/Cask/CrossCompanyCorrelatingId.cs new file mode 100644 index 0000000..5001cf9 --- /dev/null +++ b/src/Cask/CrossCompanyCorrelatingId.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; + +using static CommonAnnotatedSecurityKeys.Limits; + +namespace CommonAnnotatedSecurityKeys; + +internal static class CrossCompanyCorrelatingId +{ + /// + /// The size of a Cross-Company Correlating ID (aka C3ID) in bytes. + /// + public const int SizeInBytes = 15; + + private static ReadOnlySpan CompanyPrefix => "Cross"u8; + private static ReadOnlySpan CompanySuffix => "CorrelatingId:"u8; + private static ReadOnlySpan Hex => "0123456789ABCDEF"u8; + private const int HexCharsPerByte = 2; + + /// + /// Computes the Cross-Company Correlating Id (aka C3ID) bytes for the given + /// company and text and writes them to the destination span. + /// + public static void Compute(string company, string text, Span destination) + { + Debug.Assert(destination.Length >= SizeInBytes); + + // Input: $"Cross{company}CorrelatingId:{SHA256Hex(text))}" encoded in UTF-8 + int companyByteCount = Encoding.UTF8.GetByteCount(company); + int inputByteCount = + CompanyPrefix.Length + + companyByteCount + + CompanySuffix.Length + + (SHA256.HashSizeInBytes * HexCharsPerByte); + + Span input = inputByteCount <= MaxStackAlloc ? stackalloc byte[inputByteCount] : new byte[inputByteCount]; + Span inputDestination = input; + + // 'Cross' + CompanyPrefix.CopyTo(inputDestination); + inputDestination = inputDestination[CompanyPrefix.Length..]; + + // {company} + Encoding.UTF8.GetBytes(company.AsSpan(), inputDestination); + inputDestination = inputDestination[companyByteCount..]; + + // 'CorrelatingId:' + CompanySuffix.CopyTo(inputDestination); + inputDestination = inputDestination[CompanySuffix.Length..]; + + // SHA256 hash of UTF-8 encoded text, converted to uppercase UTF-8 encoded hex + Sha256Hex(text, inputDestination); + + // Compute second SHA256 of above input, truncate, and copy to destination + Span sha = stackalloc byte[SHA256.HashSizeInBytes]; + SHA256.HashData(input, sha); + sha[..SizeInBytes].CopyTo(destination); + } + + /// + /// Computes the SHA256 of the text encoded as UTF-8 and writes the result + /// to the destination as UTF-8 encoded uppercase hex. + /// + private static void Sha256Hex(string text, Span destination) + { + Debug.Assert(destination.Length >= SHA256.HashSizeInBytes * HexCharsPerByte); + + int byteCount = Encoding.UTF8.GetByteCount(text); + Span bytes = byteCount <= MaxStackAlloc ? stackalloc byte[byteCount] : new byte[byteCount]; + Encoding.UTF8.GetBytes(text.AsSpan(), bytes); + + Span sha = stackalloc byte[SHA256.HashSizeInBytes]; + SHA256.HashData(bytes, sha); + ConvertToHex(sha, destination); + } + + /// + /// Converts bytes to UTF-8 encoded uppercase hex. Directly, without + /// allocation or UTF-16 to UTF-8 conversion. + /// + private static void ConvertToHex(ReadOnlySpan bytes, Span destination) + { + Debug.Assert(destination.Length >= bytes.Length * HexCharsPerByte); + + for (int src = 0, dst = 0; src < bytes.Length; src++, dst += HexCharsPerByte) + { + byte b = bytes[src]; + destination[dst] = Hex[b >> 4]; + destination[dst + 1] = Hex[b & 0xF]; + } + } +} diff --git a/src/Cask/Polyfill.cs b/src/Cask/Polyfill.cs index aafabba..cda6aea 100644 --- a/src/Cask/Polyfill.cs +++ b/src/Cask/Polyfill.cs @@ -69,6 +69,11 @@ internal static class Extensions { public static unsafe string GetString(this Encoding encoding, ReadOnlySpan bytes) { + if (bytes.Length == 0) + { + return string.Empty; + } + fixed (byte* ptr = bytes) { return encoding.GetString(ptr, bytes.Length); @@ -77,6 +82,11 @@ public static unsafe string GetString(this Encoding encoding, ReadOnlySpan public static unsafe int GetByteCount(this Encoding encoding, ReadOnlySpan chars) { + if (chars.Length == 0) + { + return 0; + } + fixed (char* ptr = chars) { return encoding.GetByteCount(ptr, chars.Length); @@ -85,6 +95,11 @@ public static unsafe int GetByteCount(this Encoding encoding, ReadOnlySpan public static unsafe int GetBytes(this Encoding encoding, ReadOnlySpan chars, Span bytes) { + if (chars.Length == 0) + { + return 0; + } + fixed (char* charPtr = chars) fixed (byte* bytePtr = bytes) { diff --git a/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs b/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs new file mode 100644 index 0000000..981c102 --- /dev/null +++ b/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Xunit; + +namespace CommonAnnotatedSecurityKeys.Tests; + +public class CrossCompanyCorrelatingIdTests +{ + [Theory] + [InlineData("", "EZ3GxRsKq+Dp21GvyCpQ")] + [InlineData("Hello world", "R8ogeP7QfTFvL5qAATry")] + [InlineData("😁", "f/BTV0j6A8km4KDw7aJz")] + public void Test_Basic(string text, string expected) + { + string actual = ComputeC3IDBase64(company: "Microsoft", text); + Assert.Equal(expected, actual); + } + + [Fact] + public void Test_LargeText() + { + string actual = ComputeC3IDBase64(company: "Microsoft", text: new string('x', 300)); + Assert.Equal("QjHXB4Bu8voB3eJcJagI", actual); + } + + [Fact] + public void Test_LargeCompany() + { + string actual = ComputeC3IDBase64(company: new string('x', 300), text: "test"); + Assert.Equal("rG1CONo8M3lcBqzxyIpf", actual); + } + + private static string ComputeC3IDBase64(string company, string text) + { + byte[] bytes = new byte[CrossCompanyCorrelatingId.SizeInBytes]; + CrossCompanyCorrelatingId.Compute(company, text, bytes); + return Convert.ToBase64String(bytes); + } +} diff --git a/src/Tests/Cask.Tests/PolyfillTests.cs b/src/Tests/Cask.Tests/PolyfillTests.cs index 232e5f9..ff6d551 100644 --- a/src/Tests/Cask.Tests/PolyfillTests.cs +++ b/src/Tests/Cask.Tests/PolyfillTests.cs @@ -271,6 +271,31 @@ public void Random_NotDeterministic() Assert.False(random1.SequenceEqual(random2), "RandomNumberGenerator produced two identical 32-byte sequences."); } + [Fact] + public void Encoding_GetString_Empty() + { + ReadOnlySpan data = []; + string text = Encoding.UTF8.GetString(data); + Assert.Equal("", text); + } + + [Fact] + public void Encoding_GetByteCount_Empty() + { + ReadOnlySpan text = "".AsSpan(); + int byteCount = Encoding.UTF8.GetByteCount(text); + Assert.Equal(0, byteCount); + } + + [Fact] + public void Encoding_GetBytes_Empty() + { + ReadOnlySpan text = "".AsSpan(); + Span bytes = []; + int bytesWritten = Encoding.UTF8.GetBytes(text, bytes); + Assert.Equal(0, bytesWritten); + } + #if NETFRAMEWORK // We don't need to stress test the modern BCL :) [Fact] public async Task Polyfill_ThreadingStress()