Files
MoShiro/backend/fleet/tests/Fleet.Tests/KnockTests.cs
T
2026-10-07 18:56:40 +08:00

113 lines
5.0 KiB
C#

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<byte[]> { 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));
}
}