using System.Net; using Fleet.Identity; namespace Fleet.Tests; public sealed class KnockTests { private sealed class Clock : TimeProvider { public DateTimeOffset Now { get; set; } = new(2026, 10, 7, 12, 0, 0, TimeSpan.Zero); public override DateTimeOffset GetUtcNow() => Now; } [Fact] public void PairKey_IsTheSameOnBothSides_AndDiffersPerPair() { using NodeIdentity probe = NodeIdentity.Generate(); using NodeIdentity agent = NodeIdentity.Generate(); using NodeIdentity other = NodeIdentity.Generate(); byte[] fromProbe = Knock.DeriveKey(probe, agent.PublicKeySpki); byte[] fromAgent = Knock.DeriveKey(agent, probe.PublicKeySpki); Assert.Equal(fromProbe, fromAgent); Assert.Equal(32, fromProbe.Length); Assert.NotEqual(fromProbe, Knock.DeriveKey(other, agent.PublicKeySpki)); } [Fact] public void Packet_IsAcceptedWithinTheWindowAndOneWindowOfSkew_ButNotBeyond() { byte[] key = new byte[32]; long now = new DateTimeOffset(2026, 10, 7, 12, 0, 15, TimeSpan.Zero).ToUnixTimeMilliseconds(); byte[] packet = Knock.Build(key, now); Assert.Equal(Knock.PacketBytes, packet.Length); Assert.True(Knock.Check(key, packet, now)); Assert.True(Knock.Check(key, packet, now + (Knock.WindowSeconds * 1000))); Assert.True(Knock.Check(key, packet, now - (Knock.WindowSeconds * 1000))); Assert.False(Knock.Check(key, packet, now + (3 * Knock.WindowSeconds * 1000))); Assert.False(Knock.Check(key, packet, now - (3 * Knock.WindowSeconds * 1000))); } [Fact] public void Packet_WithAWrongKeyATamperedByteOrTheWrongLength_IsRejected() { byte[] key = new byte[32]; long now = 1_700_000_000_000; byte[] packet = Knock.Build(key, now); Assert.False(Knock.Check(new byte[32] { 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, packet, now)); foreach (int index in new[] { 0, 15, 16, 31 }) { byte[] tampered = (byte[])packet.Clone(); tampered[index] ^= 0x01; Assert.False(Knock.Check(key, tampered, now), $"byte {index}"); } Assert.False(Knock.Check(key, packet.AsSpan(0, 31), now)); Assert.False(Knock.Check(key, [], now)); } [Fact] public void Gate_AdmitsRosterMembersOnce_AndRejectsReplaysStrangersAndExpiredPackets() { using NodeIdentity agent = NodeIdentity.Generate(); using NodeIdentity probe = NodeIdentity.Generate(); using NodeIdentity stranger = NodeIdentity.Generate(); var roster = new List { probe.PublicKeySpki }; var gate = new KnockGate(agent, () => roster, new Clock()); long now = 1_700_000_000_000; byte[] packet = Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now); Assert.True(gate.Accept(packet, now)); Assert.False(gate.Accept(packet, now), "the same nonce cannot be used twice"); Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(stranger, agent.PublicKeySpki), now), now), "not on the roster"); Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now - 10 * 60_000), now), "expired"); // 名册变了:新成员马上能进,被移除的马上进不来 using NodeIdentity second = NodeIdentity.Generate(); roster.Add(second.PublicKeySpki); Assert.True(gate.Accept(Knock.Build(Knock.DeriveKey(second, agent.PublicKeySpki), now), now)); roster.RemoveAt(0); Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now), now)); } [Fact] public async Task Gate_RateLimitsEachSource_AndReadsAtMost32Bytes() { using NodeIdentity agent = NodeIdentity.Generate(); using NodeIdentity probe = NodeIdentity.Generate(); var clock = new Clock(); var gate = new KnockGate(agent, () => [probe.PublicKeySpki], clock, attemptsPerPeriod: 3); IPAddress noisy = IPAddress.Parse("203.0.113.9"); IPAddress quiet = IPAddress.Parse("203.0.113.10"); byte[] key = Knock.DeriveKey(probe, agent.PublicKeySpki); for (int i = 0; i < 3; i++) { Assert.False(await gate.AdmitAsync(new MemoryStream(new byte[Knock.PacketBytes]), noisy, default)); } // 超过限额:连有效的敲门包也不读、不算 byte[] valid = Knock.Build(key, clock.Now.ToUnixTimeMilliseconds()); Assert.False(await gate.AdmitAsync(new MemoryStream(valid), noisy, default)); Assert.True(await gate.AdmitAsync(new MemoryStream(valid), quiet, default)); // 过了统计周期,恢复 clock.Now += TimeSpan.FromSeconds(11); Assert.True(await gate.AdmitAsync(new MemoryStream(Knock.Build(key, clock.Now.ToUnixTimeMilliseconds())), noisy, default)); // 流在 32 字节之前就结束:拒绝,不抛异常 Assert.False(await gate.AdmitAsync(new MemoryStream(new byte[10]), quiet, default)); } }