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
new file mode 100644
index 0000000..cf89707
--- /dev/null
+++ b/src/Cask/CrossCompanyCorrelatingId.cs
@@ -0,0 +1,93 @@
+// 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 static CommonAnnotatedSecurityKeys.Limits;
+
+namespace CommonAnnotatedSecurityKeys;
+
+///
+/// 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 C3ID in raw bytes.
+ ///
+ public const int RawSizeInBytes = 12;
+
+ ///
+ /// 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 => "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)
+ {
+ ThrowIfNullOrEmpty(text);
+ Span bytes = stackalloc byte[PrefixBase64Decoded.Length + RawSizeInBytes];
+ PrefixBase64Decoded.CopyTo(bytes);
+ ComputeRaw(text, bytes[PrefixBase64Decoded.Length..]);
+ return Convert.ToBase64String(bytes);
+ }
+
+ ///
+ /// Computes the raw C3ID bytes for the given text and writes them to the
+ /// destination span.
+ ///
+ public static void ComputeRaw(string text, Span destination)
+ {
+ ThrowIfNull(text);
+ 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 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);
+ 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 ComputeRawUtf8(ReadOnlySpan textUtf8, Span destination)
+ {
+ 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(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/Helpers.cs b/src/Cask/Helpers.cs
index 34dccd2..2eabc3c 100644
--- a/src/Cask/Helpers.cs
+++ b/src/Cask/Helpers.cs
@@ -111,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)
{
@@ -123,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.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 aafabba..44bff92 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;
@@ -69,6 +70,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 +83,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 +96,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)
{
@@ -103,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)
@@ -136,6 +160,26 @@ 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
+ {
+ 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
@@ -213,6 +257,13 @@ public static int HashData(ReadOnlySpan source, Span destination)
Hash.Compute(sha, source, destination);
return HashSizeInBytes;
}
+
+ public static byte[] HashData(ReadOnlySpan source)
+ {
+ byte[] hash = new byte[HashSizeInBytes];
+ HashData(source, hash);
+ return hash;
+ }
}
}
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
new file mode 100644
index 0000000..55e2c40
--- /dev/null
+++ b/src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs
@@ -0,0 +1,121 @@
+// 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;
+
+public class CrossCompanyCorrelatingIdTests
+{
+ [Theory]
+ [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);
+ Assert.Equal(expected, actual);
+ }
+
+ [Fact]
+ public void C3Id_LargeText()
+ {
+ string actual = ComputeC3Id(text: new string('x', 300));
+ Assert.Equal("C3IDs+pSKJ1FmRW+7EZk", actual);
+ }
+
+ [Fact]
+ public void C3Id_Null_Throws()
+ {
+ Assert.Throws("text", () => CrossCompanyCorrelatingId.Compute(null!));
+ }
+
+ [Fact]
+ public void C3Id_Empty_Throws()
+ {
+ Assert.Throws("text", () => CrossCompanyCorrelatingId.Compute(""));
+ }
+
+ [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));
+ }
+
+ [Fact]
+ public void C3Id_EmptyRawUtf8_Throws()
+ {
+ byte[] destination = new byte[CrossCompanyCorrelatingId.RawSizeInBytes];
+ Assert.Throws("textUtf8", () => CrossCompanyCorrelatingId.ComputeRawUtf8([], destination));
+ }
+
+ [Fact]
+ public void C3Id_DestinationTooSmall_Throws()
+ {
+ byte[] destination = new byte[CrossCompanyCorrelatingId.RawSizeInBytes - 1];
+ Assert.Throws(
+ "destination",
+ () => CrossCompanyCorrelatingId.ComputeRaw("test", destination));
+ }
+
+ [Fact]
+ public void C3Id_DestinationTooSmallUtf8_Throws()
+ {
+ byte[] destination = new byte[CrossCompanyCorrelatingId.RawSizeInBytes - 1];
+ Assert.Throws(
+ "destination",
+ () => CrossCompanyCorrelatingId.ComputeRawUtf8("test"u8, destination));
+ }
+
+ private static string ComputeC3Id(string text)
+ {
+ 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 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.
+ ///
+ private static class ReferenceCrossCompanyCorrelatingId
+ {
+ 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]);
+
+ // Truncate to 12 bytes
+ hash = hash[..12];
+
+ // Convert to base64 and prepend "C3ID"
+ return "C3ID" + Convert.ToBase64String(hash);
+ }
+ }
+}
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()