From 769b694670180b9d95553f32bd2a94488116f994 Mon Sep 17 00:00:00 2001 From: Nick Guerrera Date: Thu, 9 Jan 2025 15:37:34 -0600 Subject: [PATCH] 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); + } } }