-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Update to new proposed C3ID standard, respond to PR feedback
- Loading branch information
Showing
4 changed files
with
136 additions
and
77 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
} |