Skip to content

Commit

Permalink
Update to new proposed C3ID standard, respond to PR feedback
Browse files Browse the repository at this point in the history
  • Loading branch information
nguerrera committed Jan 9, 2025
1 parent a5c7249 commit 73d2668
Show file tree
Hide file tree
Showing 4 changed files with 136 additions and 77 deletions.
110 changes: 48 additions & 62 deletions src/Cask/CrossCompanyCorrelatingId.cs
Original file line number Diff line number Diff line change
@@ -1,96 +1,82 @@
// 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
/// <summary>
/// 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".
/// </summary>
public static class CrossCompanyCorrelatingId
{
/// <summary>
/// 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.
/// </summary>
public const int SizeInBytes = 15;
public const int RawSizeInBytes = 12;

private static ReadOnlySpan<byte> CompanyPrefix => "Cross"u8;
private static ReadOnlySpan<byte> CompanySuffix => "CorrelatingId:"u8;
private static ReadOnlySpan<byte> Hex => "0123456789ABCDEF"u8;
private const int HexCharsPerByte = 2;
private static ReadOnlySpan<byte> Prefix => "C3ID"u8;
private static ReadOnlySpan<byte> PrefixBase64Decoded => [0x0B, 0x72, 0x03];

/// <summary>
/// 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.
/// </summary>
public static void Compute(string company, string text, Span<byte> 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<byte> input = inputByteCount <= MaxStackAlloc ? stackalloc byte[inputByteCount] : new byte[inputByteCount];
Span<byte> 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<byte> 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<byte> sha = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(input, sha);
sha[..SizeInBytes].CopyTo(destination);
/// <summary>
/// Computes the raw C3ID bytes for the given text and writes them to the
/// destination span.
/// </summary>
public static void Compute(string text, Span<byte> destination)
{
ThrowIfNull(text);
Compute(text.AsSpan(), destination);
}

/// <summary>
/// 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.
/// </summary>
private static void Sha256Hex(string text, Span<byte> destination)
public static void Compute(ReadOnlySpan<char> text, Span<byte> destination)
{
Debug.Assert(destination.Length >= SHA256.HashSizeInBytes * HexCharsPerByte);
ThrowIfDestinationTooSmall(destination, RawSizeInBytes);

int byteCount = Encoding.UTF8.GetByteCount(text);
Span<byte> bytes = byteCount <= MaxStackAlloc ? stackalloc byte[byteCount] : new byte[byteCount];
Encoding.UTF8.GetBytes(text.AsSpan(), bytes);

Span<byte> sha = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(bytes, sha);
ConvertToHex(sha, destination);
Span<byte> textUtf8 = byteCount <= MaxStackAlloc ? stackalloc byte[byteCount] : new byte[byteCount];
Encoding.UTF8.GetBytes(text, textUtf8);
ComputeUtf8(textUtf8, destination);
}

/// <summary>
/// Converts bytes to UTF-8 encoded uppercase hex. Directly, without
/// allocation or UTF-16 to UTF-8 conversion.
/// </summary>
private static void ConvertToHex(ReadOnlySpan<byte> bytes, Span<byte> destination)
/// Computes the raw C3ID bytes for the given UTF-8 encoded text sequence
/// and writes them to the destination span.
/// </summary>>
public static void ComputeUtf8(ReadOnlySpan<byte> textUtf8, Span<byte> 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<byte> 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<byte> sha = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(input, sha);
sha[..RawSizeInBytes].CopyTo(destination);
}
}

1 change: 1 addition & 0 deletions src/Cask/Polyfill.GlobalUsings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
16 changes: 16 additions & 0 deletions src/Cask/Polyfill.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -153,6 +154,14 @@ private static void ThrowLessThan(int value, int min, string? paramName)
}
}

internal static class Convert
{
public static string ToBase64String(ReadOnlySpan<byte> bytes)
{
return Bcl_Convert.ToBase64String(bytes.ToArray());
}
}

internal static class RandomNumberGenerator
{
// RNGCryptoServiceProvider is documented to be thread-safe so we can
Expand Down Expand Up @@ -228,6 +237,13 @@ public static int HashData(ReadOnlySpan<byte> source, Span<byte> destination)
Hash.Compute(sha, source, destination);
return HashSizeInBytes;
}

public static byte[] HashData(ReadOnlySpan<byte> source)
{
Span<byte> hash = stackalloc byte[HashSizeInBytes];
HashData(source, hash);
return hash.ToArray();
}
}
}

Expand Down
86 changes: 71 additions & 15 deletions src/Tests/Cask.Tests/CrossCompanyCorrelatingIdTests.cs
Original file line number Diff line number Diff line change
@@ -1,40 +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.Security.Cryptography;
using System.Text;

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)
[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<ArgumentNullException>("text", () => CrossCompanyCorrelatingId.Compute(null!));
}

[Fact]
public void Test_LargeCompany()
public void C3ID_DestinationTooSmall_Throws()
{
byte[] destination = new byte[CrossCompanyCorrelatingId.RawSizeInBytes - 1];
Assert.Throws<ArgumentException>(
"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<ArgumentException>(
"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;
}

/// <summary>
/// A trival reference implementation of C3ID that is easy to understand,
/// but not optimized for perfomance. We compare this to the production
/// implementation to ensure that it remains equivalent to this.
/// </summary>
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<byte> truncated = hash.AsSpan()[..12];

// Convert to base64 and prepend "C3ID"
return "C3ID" + Convert.ToBase64String(truncated);
}
}
}

0 comments on commit 73d2668

Please sign in to comment.