using System.Security.Cryptography; using DodoSSH.Domain.Sync; namespace DodoSSH.Domain.Tests.Sync; /// /// Cursor encoding and, more importantly, every way a bad cursor must be rejected. /// /// /// The negative cases are the point. An accepted-but-wrong cursor causes silent data loss — the /// client believes it is up to date while having skipped changes — which is strictly worse than an /// error the client can retry from scratch. /// public sealed class SyncCursorTests { private static readonly byte[] Key = RandomNumberGenerator.GetBytes(32); private static readonly Guid VaultId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f"); private static readonly Guid OtherVaultId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10"); [Theory] [InlineData(0L)] [InlineData(1L)] [InlineData(42L)] [InlineData(long.MaxValue)] public void RoundTrips(long sequence) { var cursor = SyncCursor.Encode(Key, VaultId, sequence); SyncCursor.TryDecode(Key, cursor, VaultId, out var decoded).ShouldBeTrue(); decoded.ShouldBe(sequence); } [Fact] public void IsDeterministic() { SyncCursor.Encode(Key, VaultId, 7).ShouldBe(SyncCursor.Encode(Key, VaultId, 7)); } [Fact] public void IsUrlSafeAndUnpadded() { var cursor = SyncCursor.Encode(Key, VaultId, 12345); cursor.ShouldNotContain("+"); cursor.ShouldNotContain("/"); cursor.ShouldNotContain("="); } [Fact] public void DoesNotRevealTheSequenceInPlainSight() { // Not a security property — the position is not secret — but it discourages clients from // parsing or synthesising cursors, which is what the opacity is actually for. SyncCursor.Encode(Key, VaultId, 987654).ShouldNotContain("987654"); } [Fact] public void RejectsATamperedTag() { var cursor = SyncCursor.Encode(Key, VaultId, 100); var tampered = cursor[..^1] + (cursor[^1] == 'A' ? 'B' : 'A'); SyncCursor.TryDecode(Key, tampered, VaultId, out _).ShouldBeFalse(); } [Fact] public void RejectsATamperedPayload() { // The attack this prevents: rewriting the sequence to skip ahead, so the client never // learns about the changes in between. var cursor = SyncCursor.Encode(Key, VaultId, 100); var mutated = cursor.ToCharArray(); mutated[0] = mutated[0] == 'x' ? 'y' : 'x'; SyncCursor.TryDecode(Key, new string(mutated), VaultId, out _).ShouldBeFalse(); } [Fact] public void RejectsACursorSignedWithAnotherKey() { var foreign = SyncCursor.Encode(RandomNumberGenerator.GetBytes(32), VaultId, 100); SyncCursor.TryDecode(Key, foreign, VaultId, out _).ShouldBeFalse(); } [Fact] public void RejectsACursorIssuedForAnotherVault() { // Legitimately issued and correctly tagged, but for a different vault. Without the vault // id inside the payload this would decode to a sequence from an unrelated log and serve // the wrong slice of history. var cursor = SyncCursor.Encode(Key, OtherVaultId, 100); SyncCursor.TryDecode(Key, cursor, VaultId, out _).ShouldBeFalse(); } [Theory] [InlineData(null)] [InlineData("")] [InlineData("not-base64!!")] [InlineData("AAAA")] [InlineData("A")] public void RejectsMalformedInput(string? cursor) { SyncCursor.TryDecode(Key, cursor, VaultId, out _).ShouldBeFalse(); } [Fact] public void RejectsATruncatedCursor() { var cursor = SyncCursor.Encode(Key, VaultId, 100); SyncCursor.TryDecode(Key, cursor[..(cursor.Length / 2)], VaultId, out _).ShouldBeFalse(); } [Fact] public void NeverThrowsOnClientSuppliedInput() { // Cursors come from clients, so rejection must be a return value rather than an exception // that becomes a 500. string[] hostile = [ "\0", "…", new string('A', 10_000), "____", "----", "v1|x|y", "%%%", ]; foreach (var value in hostile) { Should.NotThrow(() => SyncCursor.TryDecode(Key, value, VaultId, out _)); } } [Fact] public void RejectsAnUndersizedSigningKey() { // Misconfiguration must fail loudly at the call site rather than producing weak tags. Should.Throw(() => SyncCursor.Encode(new byte[16], VaultId, 1)); Should.Throw(() => SyncCursor.TryDecode(new byte[31], "whatever", VaultId, out _)); } [Fact] public void RejectsANegativeSequence() { Should.Throw(() => SyncCursor.Encode(Key, VaultId, -1)); } [Fact] public void DifferentSequencesProduceDifferentCursors() { var first = SyncCursor.Encode(Key, VaultId, 1); var second = SyncCursor.Encode(Key, VaultId, 2); string.Equals(first, second, StringComparison.Ordinal).ShouldBeFalse(); } }