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()