feat!: pre-handshake knock on the agent TCP ports; probes get the agent certificate public key [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
This commit is contained in:
1 parent
f03988d68b
commit
b99b95c738
31 files changed
+539
-61
No files matched your search
@@ -0,0 +1,112 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user