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);
+ }
}
}