Files
carmanager-3/CarManagerV3/Util/CUID.cs
2026-03-02 16:34:18 +01:00

119 lines
4.8 KiB
C#

using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace CarManagerV3.Util
{
internal class CUID
{
public const int DefaultLength = 6;
private static int _counter = RandomNumberGenerator.GetInt32(int.MaxValue);
/// <summary>
/// Generate a random CUID (Collision-resistant Unique Identifier) of a specified length.
/// The CUID is designed to be unique across different machines and time, making it suitable for use as an identifier for cars in the application.
/// The length must be between 4 and 32 characters to ensure a good balance between uniqueness and readability.
/// The generated CUID consists of a combination of alphanumeric characters to ensure uniqueness and readability.
/// </summary>
/// <param name="length">The desired length of the generated CUID</param>
/// <param name="prefixWithC">Whether to prefix the CUID with 'c' for better readability and to avoid starting with a digit.</param>"
/// <returns></returns>
public static string NewCUID(int length = DefaultLength, bool prefixWithC = true)
{
// CUIDv2 specs allow between 4 and 32 chars.
if(length < 4 || length > 32) throw new ArgumentOutOfRangeException("length");
// We will produce enough encoded chars to satisfy 'length' after prefixing and truncation.
// Base64 encodes 3 bytes -> 4 chars. So bytesNeeded ≈ ceil(charsNeeded * 3/4).
int charsNeeded = prefixWithC ? (length - 1) : length;
int bytesNeeded = (int)Math.Ceiling(charsNeeded * 3.0 / 4.0);
Span<byte> material = stackalloc byte[32];
FillMaterial(material);
byte[] outputBytes = ExpandWithSha256(material, bytesNeeded);
string encoded = Base64UrlEncode(outputBytes);
if (encoded.Length < charsNeeded)
{
// Extremely unlikely unless length is huge; ensure we have enough by expanding more.
// (Kept as a guard; for typical lengths like 24-64, you're fine.)
outputBytes = ExpandWithSha256(material, bytesNeeded + 32);
encoded = Base64UrlEncode(outputBytes);
}
string body = encoded.Substring(0, charsNeeded);
return prefixWithC ? ("c" + body) : body;
}
private static void FillMaterial(Span<byte> dst32)
{
// Compose a payload with:
// - 16 bytes random
// - 8 bytes timestamp (UTC ticks)
// - 4 bytes counter
// - 4 bytes process/thread noise
Span<byte> payload = stackalloc byte[16 + 8 + 4 + 4];
RandomNumberGenerator.Fill(payload.Slice(0, 16));
long ticks = DateTime.UtcNow.Ticks;
BinaryPrimitives.WriteInt64LittleEndian(payload.Slice(16, 8), ticks);
int c = Interlocked.Increment(ref _counter);
BinaryPrimitives.WriteInt32LittleEndian(payload.Slice(24, 4), c);
// Some extra variability (not relied on for security)
int noise = Environment.ProcessId ^ Thread.CurrentThread.ManagedThreadId ^ (int)Stopwatch.GetTimestamp();
BinaryPrimitives.WriteInt32LittleEndian(payload.Slice(28, 4), noise);
// Hash to produce 32 bytes of uniformly distributed output
SHA256.HashData(payload, dst32);
}
private static byte[] ExpandWithSha256(ReadOnlySpan<byte> seed32, int bytesNeeded)
{
if (bytesNeeded <= 0) return Array.Empty<byte>();
// If <= 32 bytes, we can just take from seed32 by hashing once more for separation.
// We'll use SHA256(seed || blockIndex) to generate blocks.
int blocks = (int)Math.Ceiling(bytesNeeded / 32.0);
byte[] result = new byte[blocks * 32];
Span<byte> input = stackalloc byte[32 + 4];
seed32.CopyTo(input.Slice(0, 32));
for (int i = 0; i < blocks; i++)
{
BinaryPrimitives.WriteInt32LittleEndian(input.Slice(32, 4), i);
Span<byte> block = result.AsSpan(i * 32, 32);
SHA256.HashData(input, block);
}
if (result.Length == bytesNeeded) return result;
byte[] trimmed = new byte[bytesNeeded];
Buffer.BlockCopy(result, 0, trimmed, 0, bytesNeeded);
return trimmed;
}
private static string Base64UrlEncode(byte[] bytes)
{
// Standard base64url without padding per RFC 4648 §5
string b64 = Convert.ToBase64String(bytes);
return b64.Replace('+', '-').Replace('/', '_').TrimEnd('=');
}
}
}