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);
}