From a5c72497e95900d97426f44beb255cdd43e431f1 Mon Sep 17 00:00:00 2001 From: Nick Guerrera Date: Tue, 7 Jan 2025 17:21:36 -0600 Subject: [PATCH 01/11] 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..be379d1 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() From 769b694670180b9d95553f32bd2a94488116f994 Mon Sep 17 00:00:00 2001 From: Nick Guerrera Date: Thu, 9 Jan 2025 15:37:34 -0600 Subject: [PATCH 02/11] Update to new proposed C3ID standard, respond to PR feedback --- src/Cask/CrossCompanyCorrelatingId.cs | 110 ++++++++---------- src/Cask/Polyfill.GlobalUsings.cs | 1 + src/Cask/Polyfill.cs | 16 +++ .../CrossCompanyCorrelatingIdTests.cs | 86 +++++++++++--- 4 files changed, 136 insertions(+), 77 deletions(-) diff --git a/src/Cask/CrossCompanyCorrelatingId.cs b/src/Cask/CrossCompanyCorrelatingId.cs index 5001cf9..919135d 100644 --- a/src/Cask/CrossCompanyCorrelatingId.cs +++ b/src/Cask/CrossCompanyCorrelatingId.cs @@ -1,7 +1,6 @@ // 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; @@ -9,88 +8,75 @@ namespace CommonAnnotatedSecurityKeys; -internal static class CrossCompanyCorrelatingId +/// +/// Cross-Company Correlating Id (C3ID) a 12-byte value used to correlate a +/// high-entropy keys with other data. The canonical textual representation is +/// base64 encoded and prefixed with "C3ID". +/// +public static class CrossCompanyCorrelatingId { /// - /// The size of a Cross-Company Correlating ID (aka C3ID) in bytes. + /// The size of a Cross-Company Correlating ID (aka C3ID) in raw bytes. /// - public const int SizeInBytes = 15; + public const int RawSizeInBytes = 12; - private static ReadOnlySpan CompanyPrefix => "Cross"u8; - private static ReadOnlySpan CompanySuffix => "CorrelatingId:"u8; - private static ReadOnlySpan Hex => "0123456789ABCDEF"u8; - private const int HexCharsPerByte = 2; + private static ReadOnlySpan Prefix => "C3ID"u8; + private static ReadOnlySpan PrefixBase64Decoded => [0x0B, 0x72, 0x03]; /// - /// Computes the Cross-Company Correlating Id (aka C3ID) bytes for the given - /// company and text and writes them to the destination span. + /// Computes the C3ID for the given text in canonical textual form. /// - public static void Compute(string company, string text, Span destination) + public static string Compute(string text) { - Debug.Assert(destination.Length >= SizeInBytes); + ThrowIfNull(text); - // 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); + Span bytes = stackalloc byte[PrefixBase64Decoded.Length + RawSizeInBytes]; + PrefixBase64Decoded.CopyTo(bytes); + Compute(text, bytes[PrefixBase64Decoded.Length..]); + return Convert.ToBase64String(bytes); + } - // 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 raw C3ID bytes for the given text and writes them to the + /// destination span. + /// + public static void Compute(string text, Span destination) + { + ThrowIfNull(text); + Compute(text.AsSpan(), destination); } /// - /// Computes the SHA256 of the text encoded as UTF-8 and writes the result - /// to the destination as UTF-8 encoded uppercase hex. + /// Computes the raw C3ID bytes for the given UTF-16 encoded text sequence + /// and writes them to the destination span. /// - private static void Sha256Hex(string text, Span destination) + public static void Compute(ReadOnlySpan text, Span destination) { - Debug.Assert(destination.Length >= SHA256.HashSizeInBytes * HexCharsPerByte); + ThrowIfDestinationTooSmall(destination, RawSizeInBytes); 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); + Span textUtf8 = byteCount <= MaxStackAlloc ? stackalloc byte[byteCount] : new byte[byteCount]; + Encoding.UTF8.GetBytes(text, textUtf8); + ComputeUtf8(textUtf8, 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) + /// Computes the raw C3ID bytes for the given UTF-8 encoded text sequence + /// and writes them to the destination span. + /// > + public static void ComputeUtf8(ReadOnlySpan textUtf8, Span destination) { - Debug.Assert(destination.Length >= bytes.Length * HexCharsPerByte); + ThrowIfDestinationTooSmall(destination, RawSizeInBytes); - 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]; - } + // Produce input for second hash: "C3ID"u8 + SHA256(text) + Span input = stackalloc byte[Prefix.Length + SHA256.HashSizeInBytes]; + Prefix.CopyTo(input); + SHA256.HashData(textUtf8, input[Prefix.Length..]); + + // Perform second hash, truncate, and copy to destination. + Span sha = stackalloc byte[SHA256.HashSizeInBytes]; + SHA256.HashData(input, sha); + sha[..RawSizeInBytes].CopyTo(destination); } } + diff --git a/src/Cask/Polyfill.GlobalUsings.cs b/src/Cask/Polyfill.GlobalUsings.cs index e224ab0..072e89b 100644 --- a/src/Cask/Polyfill.GlobalUsings.cs +++ b/src/Cask/Polyfill.GlobalUsings.cs @@ -8,6 +8,7 @@ global using static Polyfill.ArgumentValidation; +global using Convert = Polyfill.Convert; global using HMACSHA256 = Polyfill.HMACSHA256; global using RandomNumberGenerator = Polyfill.RandomNumberGenerator; global using SHA256 = Polyfill.SHA256; diff --git a/src/Cask/Polyfill.cs b/src/Cask/Polyfill.cs index cda6aea..4b17098 100644 --- a/src/Cask/Polyfill.cs +++ b/src/Cask/Polyfill.cs @@ -60,6 +60,7 @@ using System.Text; using System.Text.RegularExpressions; +using Bcl_Convert = System.Convert; using Bcl_HMACSHA256 = System.Security.Cryptography.HMACSHA256; using Bcl_SHA256 = System.Security.Cryptography.SHA256; @@ -153,6 +154,14 @@ private static void ThrowLessThan(int value, int min, string? paramName) } } + internal static class Convert + { + public static string ToBase64String(ReadOnlySpan bytes) + { + return Bcl_Convert.ToBase64String(bytes.ToArray()); + } + } + internal static class RandomNumberGenerator { // RNGCryptoServiceProvider is documented to be thread-safe so we can @@ -228,6 +237,13 @@ public static int HashData(ReadOnlySpan source, Span destination) Hash.Compute(sha, source, destination); return HashSizeInBytes; } + + public static byte[] HashData(ReadOnlySpan source) + { + Span hash = stackalloc byte[HashSizeInBytes]; + HashData(source, hash); + return hash.ToArray(); + } } } diff --git a/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs b/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs index 981c102..3aa78ba 100644 --- a/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs +++ b/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Security.Cryptography; +using System.Text; + using Xunit; namespace CommonAnnotatedSecurityKeys.Tests; @@ -8,33 +11,86 @@ 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) + [InlineData("", "C3IDKZnmJO6my5hknQ5W")] + [InlineData("Hello world", "C3IDnw4dY6uIibYownZw")] + [InlineData("😁", "C3IDF8FaWr4yMPcwOOxM")] + [InlineData("y_-KPF3BQb2-VHZeqrp28c6dgiL9y7H9TRJmQ5jJe9OvJQQJTESTBAU4AAB5mIhC", "C3IDKx9aukbRgOnPEyeu")] + [InlineData("Kq03wDtdCGWvs3sPgbH84H5MDADIJMZEERRhUN73CaGBJQQJTESTBAU4AADqe9ge", "C3IDO93RBPyuaA6ZRK8+")] + public void C3ID_Basic(string text, string expected) { - string actual = ComputeC3IDBase64(company: "Microsoft", text); + string actual = ComputeC3ID(text); Assert.Equal(expected, actual); } [Fact] - public void Test_LargeText() + public void C3ID_LargeText() + { + string actual = ComputeC3ID(text: new string('x', 300)); + Assert.Equal("C3IDs+pSKJ1FmRW+7EZk", actual); + } + + [Fact] + public void C3ID_NullInput_Throws() { - string actual = ComputeC3IDBase64(company: "Microsoft", text: new string('x', 300)); - Assert.Equal("QjHXB4Bu8voB3eJcJagI", actual); + Assert.Throws("text", () => CrossCompanyCorrelatingId.Compute(null!)); } [Fact] - public void Test_LargeCompany() + public void C3ID_DestinationTooSmall_Throws() + { + byte[] destination = new byte[CrossCompanyCorrelatingId.RawSizeInBytes - 1]; + Assert.Throws( + "destination", + () => CrossCompanyCorrelatingId.Compute("", destination)); + } + + + [Fact] + public void C3ID_DestinationTooSmallUtf8_Throws() { - string actual = ComputeC3IDBase64(company: new string('x', 300), text: "test"); - Assert.Equal("rG1CONo8M3lcBqzxyIpf", actual); + byte[] destination = new byte[CrossCompanyCorrelatingId.RawSizeInBytes - 1]; + Assert.Throws( + "destination", + () => CrossCompanyCorrelatingId.ComputeUtf8(""u8, destination)); } - private static string ComputeC3IDBase64(string company, string text) + private static string ComputeC3ID(string text) { - byte[] bytes = new byte[CrossCompanyCorrelatingId.SizeInBytes]; - CrossCompanyCorrelatingId.Compute(company, text, bytes); - return Convert.ToBase64String(bytes); + string reference = ReferenceCrossCompanyCorrelatingId.Compute(text); + string actual = CrossCompanyCorrelatingId.Compute(text); + + Assert.True( + actual == reference, + $""" + Actual implementation did not match reference implementation for '{text}'. + + reference: {reference} + actual: {actual} + """); + + return actual; + } + + /// + /// A trival reference implementation of C3ID that is easy to understand, + /// but not optimized for performance. We compare this to the production + /// implementation to ensure that it remains equivalent to this. + /// + private static class ReferenceCrossCompanyCorrelatingId + { + public static string Compute(string text) + { + // Compute the SHA-256 hash of the UTF8-encoded text + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(text)); + + // Prefix the result with "C3ID" UTF-8 bytes and hash again + hash = SHA256.HashData([.. "C3ID"u8, .. hash]); + + // Truncate to 12 bytes + ReadOnlySpan truncated = hash.AsSpan()[..12]; + + // Convert to base64 and prepend "C3ID" + return "C3ID" + Convert.ToBase64String(truncated); + } } } From 4cbd826ed380cb2067e51329bea2323c70b0c924 Mon Sep 17 00:00:00 2001 From: Nick Guerrera Date: Thu, 9 Jan 2025 17:10:05 -0600 Subject: [PATCH 03/11] Tweak to reference implementation to make it prettier --- src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs b/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs index 3aa78ba..6a44844 100644 --- a/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs +++ b/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs @@ -81,16 +81,16 @@ private static class ReferenceCrossCompanyCorrelatingId public static string Compute(string text) { // Compute the SHA-256 hash of the UTF8-encoded text - byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(text)); + Span hash = SHA256.HashData(Encoding.UTF8.GetBytes(text)); // Prefix the result with "C3ID" UTF-8 bytes and hash again hash = SHA256.HashData([.. "C3ID"u8, .. hash]); // Truncate to 12 bytes - ReadOnlySpan truncated = hash.AsSpan()[..12]; + hash = hash[..12]; // Convert to base64 and prepend "C3ID" - return "C3ID" + Convert.ToBase64String(truncated); + return "C3ID" + Convert.ToBase64String(hash); } } } From 0cdb16e710e9782479db6ce28033c78bb542605f Mon Sep 17 00:00:00 2001 From: Nick Guerrera Date: Fri, 10 Jan 2025 09:46:14 -0600 Subject: [PATCH 04/11] fixup HashData -> byte[] polyfill --- src/Cask/Polyfill.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Cask/Polyfill.cs b/src/Cask/Polyfill.cs index 4b17098..170c55f 100644 --- a/src/Cask/Polyfill.cs +++ b/src/Cask/Polyfill.cs @@ -240,9 +240,9 @@ public static int HashData(ReadOnlySpan source, Span destination) public static byte[] HashData(ReadOnlySpan source) { - Span hash = stackalloc byte[HashSizeInBytes]; + byte[] hash = new byte[HashSizeInBytes]; HashData(source, hash); - return hash.ToArray(); + return hash; } } } From a600b8b92da7d9fef9a4445abf8b4284e424046f Mon Sep 17 00:00:00 2001 From: Nick Guerrera Date: Fri, 10 Jan 2025 10:01:57 -0600 Subject: [PATCH 05/11] Avoid aka, and just use acronym after introduction in class Summary --- src/Cask/CrossCompanyCorrelatingId.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cask/CrossCompanyCorrelatingId.cs b/src/Cask/CrossCompanyCorrelatingId.cs index 919135d..1d94eec 100644 --- a/src/Cask/CrossCompanyCorrelatingId.cs +++ b/src/Cask/CrossCompanyCorrelatingId.cs @@ -16,7 +16,7 @@ namespace CommonAnnotatedSecurityKeys; public static class CrossCompanyCorrelatingId { /// - /// The size of a Cross-Company Correlating ID (aka C3ID) in raw bytes. + /// The size of a C3ID in raw bytes. /// public const int RawSizeInBytes = 12; From 611d90b982c475bf58fd7ff32744bc69e5b461fb Mon Sep 17 00:00:00 2001 From: Nick Guerrera Date: Mon, 13 Jan 2025 10:04:51 -0600 Subject: [PATCH 06/11] PR feedback - Use base64-decoded prefix for hash input and base64-encode input - Throw on empty or whitespace --- src/Cask.sln | 1 + src/Cask/CrossCompanyCorrelatingId.cs | 31 +++++----- src/Cask/Helpers.cs | 31 ++++++++++ src/Cask/Polyfill.cs | 5 ++ src/Tests/.editorconfig | 3 + .../CrossCompanyCorrelatingIdTests.cs | 61 ++++++++++++++----- 6 files changed, 103 insertions(+), 29 deletions(-) diff --git a/src/Cask.sln b/src/Cask.sln index 1dbb83a..750a177 100644 --- a/src/Cask.sln +++ b/src/Cask.sln @@ -9,6 +9,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cask", "Cask\Cask.csproj", EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{AA9664D7-21A5-4941-BE8A-D62765F58CE6}" ProjectSection(SolutionItems) = preProject + Tests\.editorconfig = Tests\.editorconfig Tests\Directory.Build.props = Tests\Directory.Build.props Tests\Directory.Packages.props = Tests\Directory.Packages.props EndProjectSection diff --git a/src/Cask/CrossCompanyCorrelatingId.cs b/src/Cask/CrossCompanyCorrelatingId.cs index 1d94eec..5746738 100644 --- a/src/Cask/CrossCompanyCorrelatingId.cs +++ b/src/Cask/CrossCompanyCorrelatingId.cs @@ -20,8 +20,12 @@ public static class CrossCompanyCorrelatingId /// public const int RawSizeInBytes = 12; - private static ReadOnlySpan Prefix => "C3ID"u8; - private static ReadOnlySpan PrefixBase64Decoded => [0x0B, 0x72, 0x03]; + /// + /// The byte sequence prepended to the input of the first hash and to the + /// input of the final base64-encoding. It is defined as the base64-decoding + /// of "C3ID". + /// + private static ReadOnlySpan Prefix => [0x0B, 0x72, 0x03]; /// /// Computes the C3ID for the given text in canonical textual form. @@ -29,10 +33,9 @@ public static class CrossCompanyCorrelatingId public static string Compute(string text) { ThrowIfNull(text); - - Span bytes = stackalloc byte[PrefixBase64Decoded.Length + RawSizeInBytes]; - PrefixBase64Decoded.CopyTo(bytes); - Compute(text, bytes[PrefixBase64Decoded.Length..]); + Span bytes = stackalloc byte[Prefix.Length + RawSizeInBytes]; + Prefix.CopyTo(bytes); + ComputeRaw(text, bytes[Prefix.Length..]); return Convert.ToBase64String(bytes); } @@ -40,38 +43,36 @@ public static string Compute(string text) /// Computes the raw C3ID bytes for the given text and writes them to the /// destination span. /// - public static void Compute(string text, Span destination) + public static void ComputeRaw(string text, Span destination) { - ThrowIfNull(text); - Compute(text.AsSpan(), destination); + ComputeRaw(text.AsSpan(), destination); } /// /// Computes the raw C3ID bytes for the given UTF-16 encoded text sequence /// and writes them to the destination span. /// - public static void Compute(ReadOnlySpan text, Span destination) + public static void ComputeRaw(ReadOnlySpan text, Span destination) { - ThrowIfDestinationTooSmall(destination, RawSizeInBytes); - int byteCount = Encoding.UTF8.GetByteCount(text); Span textUtf8 = byteCount <= MaxStackAlloc ? stackalloc byte[byteCount] : new byte[byteCount]; Encoding.UTF8.GetBytes(text, textUtf8); - ComputeUtf8(textUtf8, destination); + ComputeRawUtf8(textUtf8, destination); } /// /// Computes the raw C3ID bytes for the given UTF-8 encoded text sequence /// and writes them to the destination span. /// > - public static void ComputeUtf8(ReadOnlySpan textUtf8, Span destination) + public static void ComputeRawUtf8(ReadOnlySpan text, Span destination) { + ThrowIfEmptyOrAsciiWhitespace(text); ThrowIfDestinationTooSmall(destination, RawSizeInBytes); // Produce input for second hash: "C3ID"u8 + SHA256(text) Span input = stackalloc byte[Prefix.Length + SHA256.HashSizeInBytes]; Prefix.CopyTo(input); - SHA256.HashData(textUtf8, input[Prefix.Length..]); + SHA256.HashData(text, input[Prefix.Length..]); // Perform second hash, truncate, and copy to destination. Span sha = stackalloc byte[SHA256.HashSizeInBytes]; diff --git a/src/Cask/Helpers.cs b/src/Cask/Helpers.cs index 34dccd2..c8a1984 100644 --- a/src/Cask/Helpers.cs +++ b/src/Cask/Helpers.cs @@ -95,6 +95,37 @@ private static int RoundUpToMultipleOf(int value, int multiple) return (value + multiple - 1) / multiple * multiple; } + public static void ThrowIfEmptyOrAsciiWhitespace(ReadOnlySpan textUtf8, [CallerArgumentExpression(nameof(textUtf8))] string? paramName = null) + { + if (IsEmptyOrAsciiWhiteSpace(textUtf8)) + { + ThrowEmptyOrAsciiWhiteSpace(paramName); + } + } + + public static bool IsEmptyOrAsciiWhiteSpace(ReadOnlySpan textUtf8) + { + for (int i = 0; i < textUtf8.Length; i++) + { + if (!IsAsciiWhiteSpace((char)textUtf8[i])) + { + return false; + } + } + return true; + } + + private static bool IsAsciiWhiteSpace(char c) + { + return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\v' || c == '\f'; + } + + [DoesNotReturn] + private static void ThrowEmptyOrAsciiWhiteSpace(string? paramName) + { + throw new ArgumentException("Value cannot be empty or consist entirely of ASCII-range white space characters.", paramName); + } + public static void ThrowIfDefault(T value, [CallerArgumentExpression(nameof(value))] string? paramName = null) where T : struct { if (EqualityComparer.Default.Equals(value, default)) diff --git a/src/Cask/Polyfill.cs b/src/Cask/Polyfill.cs index 170c55f..67cdfe6 100644 --- a/src/Cask/Polyfill.cs +++ b/src/Cask/Polyfill.cs @@ -160,6 +160,11 @@ public static string ToBase64String(ReadOnlySpan bytes) { return Bcl_Convert.ToBase64String(bytes.ToArray()); } + + public static byte[] FromBase64String(string s) + { + return Bcl_Convert.FromBase64String(s); + } } internal static class RandomNumberGenerator diff --git a/src/Tests/.editorconfig b/src/Tests/.editorconfig index bee9642..4e47142 100644 --- a/src/Tests/.editorconfig +++ b/src/Tests/.editorconfig @@ -15,3 +15,6 @@ dotnet_diagnostic.CA1707.severity = silent # CA1822: Mark members as static dotnet_diagnostic.CA1822.severity = silent + +# CA1062: Validate arguments of public methods +dotnet_diagnostic.CA1062.severity = silent diff --git a/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs b/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs index 6a44844..a06a4b5 100644 --- a/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs +++ b/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs @@ -11,11 +11,10 @@ namespace CommonAnnotatedSecurityKeys.Tests; public class CrossCompanyCorrelatingIdTests { [Theory] - [InlineData("", "C3IDKZnmJO6my5hknQ5W")] - [InlineData("Hello world", "C3IDnw4dY6uIibYownZw")] - [InlineData("😁", "C3IDF8FaWr4yMPcwOOxM")] - [InlineData("y_-KPF3BQb2-VHZeqrp28c6dgiL9y7H9TRJmQ5jJe9OvJQQJTESTBAU4AAB5mIhC", "C3IDKx9aukbRgOnPEyeu")] - [InlineData("Kq03wDtdCGWvs3sPgbH84H5MDADIJMZEERRhUN73CaGBJQQJTESTBAU4AADqe9ge", "C3IDO93RBPyuaA6ZRK8+")] + [InlineData("Hello world", "C3ID9xeTAR1ewMzk9axi")] + [InlineData("😁", "C3IDrASY+FVWgFfMcvcw")] + [InlineData("y_-KPF3BQb2-VHZeqrp28c6dgiL9y7H9TRJmQ5jJe9OvJQQJTESTBAU4AAB5mIhC", "C3IDNucDCyn9NEm713r5")] + [InlineData("Kq03wDtdCGWvs3sPgbH84H5MDADIJMZEERRhUN73CaGBJQQJTESTBAU4AADqe9ge", "C3IDHW9XUFlW+lHLTNFU")] public void C3ID_Basic(string text, string expected) { string actual = ComputeC3ID(text); @@ -26,32 +25,64 @@ public void C3ID_Basic(string text, string expected) public void C3ID_LargeText() { string actual = ComputeC3ID(text: new string('x', 300)); - Assert.Equal("C3IDs+pSKJ1FmRW+7EZk", actual); + Assert.Equal("C3IDuGJvUr8Loa+4dgYT", actual); } [Fact] - public void C3ID_NullInput_Throws() + public void C3ID_Null_Throws() { Assert.Throws("text", () => CrossCompanyCorrelatingId.Compute(null!)); } + public static readonly TheoryData EmptyOrAsciiWhitespace = + [ + "", + " ", + " ", + " \t\r\n\u000B\u000C ", + ]; + + [Theory] + [MemberData(nameof(EmptyOrAsciiWhitespace))] + public void C3ID_EmptyOrAsciiWhitespace_Throws(string text) + { + Assert.Throws(nameof(text), () => CrossCompanyCorrelatingId.Compute(text)); + } + + [Theory] + [MemberData(nameof(EmptyOrAsciiWhitespace))] + public void C3ID_EmptyOrAsciiWhitespaceRaw_Throws(string text) + { + char[] input = text.ToCharArray(); + byte[] destination = new byte[CrossCompanyCorrelatingId.RawSizeInBytes]; + Assert.Throws(nameof(text), () => CrossCompanyCorrelatingId.ComputeRaw(input, destination)); + } + + [Theory] + [MemberData(nameof(EmptyOrAsciiWhitespace))] + public void C3ID_EmptyOrAsciiWhitespaceRawUtf8_Throws(string text) + { + byte[] input = Encoding.UTF8.GetBytes(text); + byte[] destination = new byte[CrossCompanyCorrelatingId.RawSizeInBytes]; + Assert.Throws(nameof(text), () => CrossCompanyCorrelatingId.ComputeRawUtf8(input, destination)); + } + [Fact] public void C3ID_DestinationTooSmall_Throws() { byte[] destination = new byte[CrossCompanyCorrelatingId.RawSizeInBytes - 1]; Assert.Throws( "destination", - () => CrossCompanyCorrelatingId.Compute("", destination)); + () => CrossCompanyCorrelatingId.ComputeRaw("test", destination)); } - [Fact] public void C3ID_DestinationTooSmallUtf8_Throws() { byte[] destination = new byte[CrossCompanyCorrelatingId.RawSizeInBytes - 1]; Assert.Throws( "destination", - () => CrossCompanyCorrelatingId.ComputeUtf8(""u8, destination)); + () => CrossCompanyCorrelatingId.ComputeRawUtf8("test"u8, destination)); } private static string ComputeC3ID(string text) @@ -78,19 +109,21 @@ private static string ComputeC3ID(string text) /// private static class ReferenceCrossCompanyCorrelatingId { + private static readonly byte[] s_prefix = Convert.FromBase64String("C3ID"); + public static string Compute(string text) { // Compute the SHA-256 hash of the UTF8-encoded text Span hash = SHA256.HashData(Encoding.UTF8.GetBytes(text)); - // Prefix the result with "C3ID" UTF-8 bytes and hash again - hash = SHA256.HashData([.. "C3ID"u8, .. hash]); + // Prefix the result and hash again + hash = SHA256.HashData([.. s_prefix, .. hash]); // Truncate to 12 bytes hash = hash[..12]; - // Convert to base64 and prepend "C3ID" - return "C3ID" + Convert.ToBase64String(hash); + // Prefix the result and convert to base64 + return Convert.ToBase64String([.. s_prefix, .. hash]); } } } From cfb68a708049d12f9fe008e63ed1d60dfb89cd49 Mon Sep 17 00:00:00 2001 From: Nick Guerrera Date: Mon, 13 Jan 2025 15:33:41 -0600 Subject: [PATCH 07/11] Update src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs --- src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs b/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs index a06a4b5..9a20a9f 100644 --- a/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs +++ b/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs @@ -103,7 +103,7 @@ private static string ComputeC3ID(string text) } /// - /// A trival reference implementation of C3ID that is easy to understand, + /// A trivial reference implementation of C3ID that is easy to understand, /// but not optimized for performance. We compare this to the production /// implementation to ensure that it remains equivalent to this. /// From 0591c455eeff90a9b6c7b444b3cbe834fe8bfe54 Mon Sep 17 00:00:00 2001 From: Nick Guerrera Date: Mon, 13 Jan 2025 15:56:32 -0600 Subject: [PATCH 08/11] Phone call feedback. Re-simplify. --- src/Cask/CrossCompanyCorrelatingId.cs | 32 ++++++++---- src/Cask/Helpers.cs | 44 +++++----------- src/Cask/Polyfill.cs | 20 ++++++-- .../CrossCompanyCorrelatingIdTests.cs | 51 +++++++------------ 4 files changed, 67 insertions(+), 80 deletions(-) diff --git a/src/Cask/CrossCompanyCorrelatingId.cs b/src/Cask/CrossCompanyCorrelatingId.cs index 5746738..cf89707 100644 --- a/src/Cask/CrossCompanyCorrelatingId.cs +++ b/src/Cask/CrossCompanyCorrelatingId.cs @@ -21,21 +21,27 @@ public static class CrossCompanyCorrelatingId public const int RawSizeInBytes = 12; /// - /// The byte sequence prepended to the input of the first hash and to the - /// input of the final base64-encoding. It is defined as the base64-decoding - /// of "C3ID". + /// The byte sequence prepended to the input for the first SHA256 hash. It + /// is defined as the UTF-8 encoding of "C3ID". /// - private static ReadOnlySpan Prefix => [0x0B, 0x72, 0x03]; + private static ReadOnlySpan Prefix => "C3ID"u8; + + /// + /// The byte sequence prepended to the to the output of the + /// base64-encoding. It is defined as the base64-decoding of "C3ID". This + /// results in all canonical base64 encoded C3IDs starting with "C3ID". + /// + private static ReadOnlySpan PrefixBase64Decoded => [0x0B, 0x72, 0x03]; /// /// Computes the C3ID for the given text in canonical textual form. /// public static string Compute(string text) { - ThrowIfNull(text); - Span bytes = stackalloc byte[Prefix.Length + RawSizeInBytes]; - Prefix.CopyTo(bytes); - ComputeRaw(text, bytes[Prefix.Length..]); + ThrowIfNullOrEmpty(text); + Span bytes = stackalloc byte[PrefixBase64Decoded.Length + RawSizeInBytes]; + PrefixBase64Decoded.CopyTo(bytes); + ComputeRaw(text, bytes[PrefixBase64Decoded.Length..]); return Convert.ToBase64String(bytes); } @@ -45,6 +51,7 @@ public static string Compute(string text) /// public static void ComputeRaw(string text, Span destination) { + ThrowIfNull(text); ComputeRaw(text.AsSpan(), destination); } @@ -54,6 +61,9 @@ public static void ComputeRaw(string text, Span destination) /// public static void ComputeRaw(ReadOnlySpan text, Span destination) { + ThrowIfEmpty(text); + ThrowIfDestinationTooSmall(destination, RawSizeInBytes); + int byteCount = Encoding.UTF8.GetByteCount(text); Span textUtf8 = byteCount <= MaxStackAlloc ? stackalloc byte[byteCount] : new byte[byteCount]; Encoding.UTF8.GetBytes(text, textUtf8); @@ -64,15 +74,15 @@ public static void ComputeRaw(ReadOnlySpan text, Span destination) /// Computes the raw C3ID bytes for the given UTF-8 encoded text sequence /// and writes them to the destination span. /// > - public static void ComputeRawUtf8(ReadOnlySpan text, Span destination) + public static void ComputeRawUtf8(ReadOnlySpan textUtf8, Span destination) { - ThrowIfEmptyOrAsciiWhitespace(text); + ThrowIfEmpty(textUtf8); ThrowIfDestinationTooSmall(destination, RawSizeInBytes); // Produce input for second hash: "C3ID"u8 + SHA256(text) Span input = stackalloc byte[Prefix.Length + SHA256.HashSizeInBytes]; Prefix.CopyTo(input); - SHA256.HashData(text, input[Prefix.Length..]); + SHA256.HashData(textUtf8, input[Prefix.Length..]); // Perform second hash, truncate, and copy to destination. Span sha = stackalloc byte[SHA256.HashSizeInBytes]; diff --git a/src/Cask/Helpers.cs b/src/Cask/Helpers.cs index c8a1984..2eabc3c 100644 --- a/src/Cask/Helpers.cs +++ b/src/Cask/Helpers.cs @@ -95,37 +95,6 @@ private static int RoundUpToMultipleOf(int value, int multiple) return (value + multiple - 1) / multiple * multiple; } - public static void ThrowIfEmptyOrAsciiWhitespace(ReadOnlySpan textUtf8, [CallerArgumentExpression(nameof(textUtf8))] string? paramName = null) - { - if (IsEmptyOrAsciiWhiteSpace(textUtf8)) - { - ThrowEmptyOrAsciiWhiteSpace(paramName); - } - } - - public static bool IsEmptyOrAsciiWhiteSpace(ReadOnlySpan textUtf8) - { - for (int i = 0; i < textUtf8.Length; i++) - { - if (!IsAsciiWhiteSpace((char)textUtf8[i])) - { - return false; - } - } - return true; - } - - private static bool IsAsciiWhiteSpace(char c) - { - return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\v' || c == '\f'; - } - - [DoesNotReturn] - private static void ThrowEmptyOrAsciiWhiteSpace(string? paramName) - { - throw new ArgumentException("Value cannot be empty or consist entirely of ASCII-range white space characters.", paramName); - } - public static void ThrowIfDefault(T value, [CallerArgumentExpression(nameof(value))] string? paramName = null) where T : struct { if (EqualityComparer.Default.Equals(value, default)) @@ -142,6 +111,14 @@ public static void ThrowIfDestinationTooSmall(Span destination, int requir } } + public static void ThrowIfEmpty(ReadOnlySpan value, [CallerArgumentExpression(nameof(value))] string? paramName = null) + { + if (value.IsEmpty) + { + ThrowEmpty(paramName); + } + } + [DoesNotReturn] private static void ThrowDefault(string? paramName) { @@ -154,4 +131,9 @@ private static void ThrowDestinationTooSmall(string? paramName) throw new ArgumentException("Destination buffer is too small.", paramName); } + [DoesNotReturn] + private static void ThrowEmpty(string? paramName) + { + throw new ArgumentException("Value cannot be empty.", paramName); + } } diff --git a/src/Cask/Polyfill.cs b/src/Cask/Polyfill.cs index 67cdfe6..afb7c5d 100644 --- a/src/Cask/Polyfill.cs +++ b/src/Cask/Polyfill.cs @@ -119,6 +119,14 @@ public static void ThrowIfNull([NotNull] object? argument, [CallerArgumentExpres } } + public static void ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) + { + if (string.IsNullOrEmpty(argument)) + { + ThrowNullOrEmpty(argument, paramName); + } + } + public static void ThrowIfGreaterThan(int value, int max, [CallerArgumentExpression(nameof(value))] string? paramName = null) { if (value > max) @@ -152,6 +160,13 @@ private static void ThrowLessThan(int value, int min, string? paramName) { throw new ArgumentOutOfRangeException(paramName, value, $"Value must be greater than or equal to {min}."); } + + [DoesNotReturn] + private static void ThrowNullOrEmpty(string? argument, string? paramName) + { + ThrowIfNull(argument, paramName); + throw new ArgumentException("Value cannot be empty.", paramName); + } } internal static class Convert @@ -160,11 +175,6 @@ public static string ToBase64String(ReadOnlySpan bytes) { return Bcl_Convert.ToBase64String(bytes.ToArray()); } - - public static byte[] FromBase64String(string s) - { - return Bcl_Convert.FromBase64String(s); - } } internal static class RandomNumberGenerator diff --git a/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs b/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs index 9a20a9f..aa00675 100644 --- a/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs +++ b/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs @@ -11,10 +11,10 @@ namespace CommonAnnotatedSecurityKeys.Tests; public class CrossCompanyCorrelatingIdTests { [Theory] - [InlineData("Hello world", "C3ID9xeTAR1ewMzk9axi")] - [InlineData("😁", "C3IDrASY+FVWgFfMcvcw")] - [InlineData("y_-KPF3BQb2-VHZeqrp28c6dgiL9y7H9TRJmQ5jJe9OvJQQJTESTBAU4AAB5mIhC", "C3IDNucDCyn9NEm713r5")] - [InlineData("Kq03wDtdCGWvs3sPgbH84H5MDADIJMZEERRhUN73CaGBJQQJTESTBAU4AADqe9ge", "C3IDHW9XUFlW+lHLTNFU")] + [InlineData("Hello world", "C3IDnw4dY6uIibYownZw")] + [InlineData("😁", "C3IDF8FaWr4yMPcwOOxM")] + [InlineData("y_-KPF3BQb2-VHZeqrp28c6dgiL9y7H9TRJmQ5jJe9OvJQQJTESTBAU4AAB5mIhC", "C3IDKx9aukbRgOnPEyeu")] + [InlineData("Kq03wDtdCGWvs3sPgbH84H5MDADIJMZEERRhUN73CaGBJQQJTESTBAU4AADqe9ge", "C3IDO93RBPyuaA6ZRK8+")] public void C3ID_Basic(string text, string expected) { string actual = ComputeC3ID(text); @@ -25,7 +25,7 @@ public void C3ID_Basic(string text, string expected) public void C3ID_LargeText() { string actual = ComputeC3ID(text: new string('x', 300)); - Assert.Equal("C3IDuGJvUr8Loa+4dgYT", actual); + Assert.Equal("C3IDs+pSKJ1FmRW+7EZk", actual); } [Fact] @@ -34,37 +34,24 @@ public void C3ID_Null_Throws() Assert.Throws("text", () => CrossCompanyCorrelatingId.Compute(null!)); } - public static readonly TheoryData EmptyOrAsciiWhitespace = - [ - "", - " ", - " ", - " \t\r\n\u000B\u000C ", - ]; - - [Theory] - [MemberData(nameof(EmptyOrAsciiWhitespace))] - public void C3ID_EmptyOrAsciiWhitespace_Throws(string text) + [Fact] + public void C3ID_Empty_Throws() { - Assert.Throws(nameof(text), () => CrossCompanyCorrelatingId.Compute(text)); + Assert.Throws("text", () => CrossCompanyCorrelatingId.Compute("")); } - [Theory] - [MemberData(nameof(EmptyOrAsciiWhitespace))] - public void C3ID_EmptyOrAsciiWhitespaceRaw_Throws(string text) + [Fact] + public void C3ID_EmptyRaw_Throws() { - char[] input = text.ToCharArray(); byte[] destination = new byte[CrossCompanyCorrelatingId.RawSizeInBytes]; - Assert.Throws(nameof(text), () => CrossCompanyCorrelatingId.ComputeRaw(input, destination)); + Assert.Throws("text", () => CrossCompanyCorrelatingId.ComputeRaw([], destination)); } - [Theory] - [MemberData(nameof(EmptyOrAsciiWhitespace))] - public void C3ID_EmptyOrAsciiWhitespaceRawUtf8_Throws(string text) + [Fact] + public void C3ID_EmptyRawUtf8_Throws() { - byte[] input = Encoding.UTF8.GetBytes(text); byte[] destination = new byte[CrossCompanyCorrelatingId.RawSizeInBytes]; - Assert.Throws(nameof(text), () => CrossCompanyCorrelatingId.ComputeRawUtf8(input, destination)); + Assert.Throws("textUtf8", () => CrossCompanyCorrelatingId.ComputeRawUtf8([], destination)); } [Fact] @@ -109,21 +96,19 @@ private static string ComputeC3ID(string text) /// private static class ReferenceCrossCompanyCorrelatingId { - private static readonly byte[] s_prefix = Convert.FromBase64String("C3ID"); - public static string Compute(string text) { // Compute the SHA-256 hash of the UTF8-encoded text Span hash = SHA256.HashData(Encoding.UTF8.GetBytes(text)); - // Prefix the result and hash again - hash = SHA256.HashData([.. s_prefix, .. hash]); + // Prefix the result with "C3ID" UTF-8 bytes and hash again + hash = SHA256.HashData([.. "C3ID"u8, .. hash]); // Truncate to 12 bytes hash = hash[..12]; - // Prefix the result and convert to base64 - return Convert.ToBase64String([.. s_prefix, .. hash]); + // Convert to base64 and prepend "C3ID" + return "C3ID" + Convert.ToBase64String(hash); } } } From 46b9bdb6d1dad8400b19745303384bc96675bac6 Mon Sep 17 00:00:00 2001 From: Nick Guerrera Date: Mon, 13 Jan 2025 16:18:53 -0600 Subject: [PATCH 09/11] Additional test --- src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs b/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs index aa00675..ffa5794 100644 --- a/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs +++ b/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs @@ -42,6 +42,13 @@ public void C3ID_Empty_Throws() [Fact] public void C3ID_EmptyRaw_Throws() + { + byte[] destination = new byte[CrossCompanyCorrelatingId.RawSizeInBytes]; + Assert.Throws("text", () => CrossCompanyCorrelatingId.ComputeRaw("", destination)); + } + + [Fact] + public void C3ID_EmptyRawSpan_Throws() { byte[] destination = new byte[CrossCompanyCorrelatingId.RawSizeInBytes]; Assert.Throws("text", () => CrossCompanyCorrelatingId.ComputeRaw([], destination)); From 6797275976d061e9cd791746085798b89c90ecc1 Mon Sep 17 00:00:00 2001 From: Nick Guerrera Date: Wed, 15 Jan 2025 10:13:38 -0600 Subject: [PATCH 10/11] Change casing in test API C3ID -> C3Id --- .../CrossCompanyCorrelatingIdTests.cs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs b/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs index ffa5794..55e2c40 100644 --- a/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs +++ b/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs @@ -15,54 +15,54 @@ public class CrossCompanyCorrelatingIdTests [InlineData("😁", "C3IDF8FaWr4yMPcwOOxM")] [InlineData("y_-KPF3BQb2-VHZeqrp28c6dgiL9y7H9TRJmQ5jJe9OvJQQJTESTBAU4AAB5mIhC", "C3IDKx9aukbRgOnPEyeu")] [InlineData("Kq03wDtdCGWvs3sPgbH84H5MDADIJMZEERRhUN73CaGBJQQJTESTBAU4AADqe9ge", "C3IDO93RBPyuaA6ZRK8+")] - public void C3ID_Basic(string text, string expected) + public void C3Id_Basic(string text, string expected) { - string actual = ComputeC3ID(text); + string actual = ComputeC3Id(text); Assert.Equal(expected, actual); } [Fact] - public void C3ID_LargeText() + public void C3Id_LargeText() { - string actual = ComputeC3ID(text: new string('x', 300)); + string actual = ComputeC3Id(text: new string('x', 300)); Assert.Equal("C3IDs+pSKJ1FmRW+7EZk", actual); } [Fact] - public void C3ID_Null_Throws() + public void C3Id_Null_Throws() { Assert.Throws("text", () => CrossCompanyCorrelatingId.Compute(null!)); } [Fact] - public void C3ID_Empty_Throws() + public void C3Id_Empty_Throws() { Assert.Throws("text", () => CrossCompanyCorrelatingId.Compute("")); } [Fact] - public void C3ID_EmptyRaw_Throws() + public void C3Id_EmptyRaw_Throws() { byte[] destination = new byte[CrossCompanyCorrelatingId.RawSizeInBytes]; Assert.Throws("text", () => CrossCompanyCorrelatingId.ComputeRaw("", destination)); } [Fact] - public void C3ID_EmptyRawSpan_Throws() + public void C3Id_EmptyRawSpan_Throws() { byte[] destination = new byte[CrossCompanyCorrelatingId.RawSizeInBytes]; Assert.Throws("text", () => CrossCompanyCorrelatingId.ComputeRaw([], destination)); } [Fact] - public void C3ID_EmptyRawUtf8_Throws() + public void C3Id_EmptyRawUtf8_Throws() { byte[] destination = new byte[CrossCompanyCorrelatingId.RawSizeInBytes]; Assert.Throws("textUtf8", () => CrossCompanyCorrelatingId.ComputeRawUtf8([], destination)); } [Fact] - public void C3ID_DestinationTooSmall_Throws() + public void C3Id_DestinationTooSmall_Throws() { byte[] destination = new byte[CrossCompanyCorrelatingId.RawSizeInBytes - 1]; Assert.Throws( @@ -71,7 +71,7 @@ public void C3ID_DestinationTooSmall_Throws() } [Fact] - public void C3ID_DestinationTooSmallUtf8_Throws() + public void C3Id_DestinationTooSmallUtf8_Throws() { byte[] destination = new byte[CrossCompanyCorrelatingId.RawSizeInBytes - 1]; Assert.Throws( @@ -79,7 +79,7 @@ public void C3ID_DestinationTooSmallUtf8_Throws() () => CrossCompanyCorrelatingId.ComputeRawUtf8("test"u8, destination)); } - private static string ComputeC3ID(string text) + private static string ComputeC3Id(string text) { string reference = ReferenceCrossCompanyCorrelatingId.Compute(text); string actual = CrossCompanyCorrelatingId.Compute(text); From 4e56028044264d4c432efbf6eeaee33a89862713 Mon Sep 17 00:00:00 2001 From: Nick Guerrera Date: Wed, 15 Jan 2025 11:35:40 -0600 Subject: [PATCH 11/11] Fix build after merge --- src/Cask/Polyfill.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Cask/Polyfill.cs b/src/Cask/Polyfill.cs index afb7c5d..44bff92 100644 --- a/src/Cask/Polyfill.cs +++ b/src/Cask/Polyfill.cs @@ -175,6 +175,11 @@ public static string ToBase64String(ReadOnlySpan bytes) { return Bcl_Convert.ToBase64String(bytes.ToArray()); } + + public static byte[] FromBase64String(string base64) + { + return Bcl_Convert.FromBase64String(base64); + } } internal static class RandomNumberGenerator