namespace DodoSSH.Crypto.Tests; /// The key log hash chain. See docs/crypto.md ยง7.2. public sealed class KeyLogChainTests { private static readonly Guid Alice = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f"); private static readonly Guid Bob = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10"); private static readonly DateTimeOffset CreatedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123); [Fact] public void GenesisPreviousHash_IsThirtyTwoZeroBytes() { var genesis = KeyLogChain.CreateGenesisPreviousHash(); genesis.Length.ShouldBe(CryptoSpec.DigestSize); genesis.ShouldAllBe(b => b == 0); } [Fact] public void GenesisPreviousHash_IsANewArrayEachCall() { // Shared mutable state in a hash input would be a spectacular way to corrupt a chain. var first = KeyLogChain.CreateGenesisPreviousHash(); first[0] = 0xFF; KeyLogChain.CreateGenesisPreviousHash()[0].ShouldBe((byte)0); } [Fact] public void ComputeEntryHash_IsDeterministic() { Hash().ShouldBe(Hash()); } [Fact] public void ChangingThePreviousHash_ChangesTheEntryHash() { // The link itself. If this held, a server could reorder or drop entries undetectably. var linked = Hash(previousHash: Hash()); linked.ShouldNotBe(Hash()); } [Theory] [InlineData("user")] [InlineData("generation")] [InlineData("encryptionKey")] [InlineData("signingKey")] [InlineData("signature")] [InlineData("createdAt")] public void ChangingAnyField_ChangesTheEntryHash(string field) { var baseline = Hash(); var altered = field switch { "user" => Hash(userId: Bob), "generation" => Hash(generation: 2), "encryptionKey" => Hash(encryptionPublicKey: TestKeys.Alternate), "signingKey" => Hash(signingPublicKey: TestKeys.Alternate), "signature" => Hash(signature: AlternateSignature), "createdAt" => Hash(createdAt: CreatedAt.AddMilliseconds(1)), _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unknown field."), }; altered.ShouldNotBe(baseline); } [Fact] public void SubMillisecondPrecision_IsTruncatedAway() { // The stored column round-trips through PostgreSQL's microseconds. If the hash used finer // precision than the storage, no entry could ever reproduce its own hash after being read. Hash(createdAt: CreatedAt.AddTicks(9_999)).ShouldBe(Hash(createdAt: CreatedAt)); } [Fact] public void TruncateTimestamp_MatchesWhatTheHashUses() { var truncated = KeyLogChain.TruncateTimestamp(CreatedAt.AddTicks(9_999)); truncated.ToUnixTimeMilliseconds().ShouldBe(CreatedAt.ToUnixTimeMilliseconds()); truncated.Ticks.ShouldBe(truncated.Ticks / TimeSpan.TicksPerMillisecond * TimeSpan.TicksPerMillisecond); truncated.Offset.ShouldBe(TimeSpan.Zero); } [Theory] [InlineData(0)] [InlineData(31)] [InlineData(33)] public void ComputeEntryHash_RejectsAWrongLengthPreviousHash(int length) { Should.Throw(() => Hash(previousHash: new byte[length])); } [Fact] public void ComputeEntryHash_RejectsAWrongLengthSignature() { Should.Throw(() => Hash(signature: new byte[32])); } [Fact] public void ComputeEntryHash_RejectsAGenerationBelowOne() { Should.Throw(() => Hash(generation: 0)); } private static byte[] AlternateSignature { get; } = [.. Enumerable.Range(0, CryptoSpec.SignatureSize).Select(i => (byte)(0xC0 + i))]; private static byte[] Signature { get; } = [.. Enumerable.Range(0, CryptoSpec.SignatureSize).Select(i => (byte)(0x80 + i))]; private static byte[] Hash( byte[]? previousHash = null, Guid? userId = null, int generation = 1, byte[]? encryptionPublicKey = null, byte[]? signingPublicKey = null, byte[]? signature = null, DateTimeOffset? createdAt = null) => KeyLogChain.ComputeEntryHash( previousHash ?? KeyLogChain.CreateGenesisPreviousHash(), userId ?? Alice, generation, encryptionPublicKey ?? TestKeys.Encryption, signingPublicKey ?? TestKeys.Signing, signature ?? Signature, createdAt ?? CreatedAt); }