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,80 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Fleet.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// 预握手敲门:TCP 连接建立之后、TLS 握手之前,拨号方先发 32 字节 <c>nonce(16) | tag(16)</c>,
|
||||
/// <c>tag = HMAC-SHA256(K_pair, 时间窗 | nonce)</c> 截断到 16 字节。监听方核对不过就什么都不写、直接断开,
|
||||
/// 所以扫描器拿不到证书、TLS 参数和 JARM 指纹。<c>K_pair</c> 只有名册里的成员算得出:双方身份密钥做静态 ECDH,再经 HKDF 加用途标签派生。
|
||||
/// TCP 的 SYN-ACK 由内核发出,端口在扫描器眼里仍是「开着但不说话」,不是「没监听」。
|
||||
/// </summary>
|
||||
public static class Knock
|
||||
{
|
||||
public const int PacketBytes = 32;
|
||||
public const int NonceBytes = 16;
|
||||
public const int TagBytes = 16;
|
||||
|
||||
/// <summary>时间窗长度;接受前后各一个窗,容忍时钟偏差。</summary>
|
||||
public const int WindowSeconds = 30;
|
||||
|
||||
private const string Label = "moshiro-knock-v1";
|
||||
|
||||
/// <summary>两把身份密钥的配对密钥。info 里的两个公钥按字节序排列,所以两边算出的是同一个值。</summary>
|
||||
public static byte[] DeriveKey(NodeIdentity own, ReadOnlySpan<byte> peerSpki)
|
||||
{
|
||||
byte[] secret = own.AgreeWith(peerSpki);
|
||||
try
|
||||
{
|
||||
ReadOnlySpan<byte> mine = own.PublicKeySpki;
|
||||
bool mineFirst = mine.SequenceCompareTo(peerSpki) <= 0;
|
||||
byte[] info = [.. Encoding.UTF8.GetBytes(Label), .. (mineFirst ? mine : peerSpki), .. (mineFirst ? peerSpki : mine)];
|
||||
return HKDF.DeriveKey(HashAlgorithmName.SHA256, secret, 32, null, info);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(secret);
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] Build(ReadOnlySpan<byte> key, long nowMs)
|
||||
{
|
||||
byte[] packet = new byte[PacketBytes];
|
||||
RandomNumberGenerator.Fill(packet.AsSpan(0, NonceBytes));
|
||||
Tag(key, WindowOf(nowMs), packet.AsSpan(0, NonceBytes), packet.AsSpan(NonceBytes));
|
||||
return packet;
|
||||
}
|
||||
|
||||
/// <summary>tag 在前一个、当前、后一个时间窗里有一个对得上就通过。不管重放(重放由 <see cref="KnockGate"/> 按 nonce 去重)。</summary>
|
||||
public static bool Check(ReadOnlySpan<byte> key, ReadOnlySpan<byte> packet, long nowMs)
|
||||
{
|
||||
if (packet.Length != PacketBytes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long window = WindowOf(nowMs);
|
||||
Span<byte> expected = stackalloc byte[TagBytes];
|
||||
bool ok = false;
|
||||
for (long w = window - 1; w <= window + 1; w++)
|
||||
{
|
||||
Tag(key, w, packet[..NonceBytes], expected);
|
||||
ok |= CryptographicOperations.FixedTimeEquals(expected, packet[NonceBytes..]);
|
||||
}
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
private static long WindowOf(long nowMs) => nowMs / 1000 / WindowSeconds;
|
||||
|
||||
private static void Tag(ReadOnlySpan<byte> key, long window, ReadOnlySpan<byte> nonce, Span<byte> destination)
|
||||
{
|
||||
Span<byte> data = stackalloc byte[8 + NonceBytes];
|
||||
BinaryPrimitives.WriteInt64BigEndian(data, window);
|
||||
nonce.CopyTo(data[8..]);
|
||||
Span<byte> full = stackalloc byte[32];
|
||||
HMACSHA256.HashData(key, data, full);
|
||||
full[..TagBytes].CopyTo(destination);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Fleet.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// 监听方的敲门关卡:新连接的前 32 字节必须是名册里某个成员的敲门包,否则不写任何字节、直接断开。
|
||||
/// 每个来源地址限速(陌生人用随机数据也要消耗 HMAC 计算),nonce 在时间窗内去重(防重放)。
|
||||
/// 名册由调用方提供(对端公钥列表);配对密钥按公钥缓存,名册变了自然只算新的。
|
||||
/// </summary>
|
||||
public sealed class KnockGate
|
||||
{
|
||||
private static readonly TimeSpan s_readTimeout = TimeSpan.FromSeconds(3);
|
||||
|
||||
private readonly NodeIdentity _own;
|
||||
private readonly Func<IReadOnlyList<byte[]>> _peers;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly int _attemptsPerPeriod;
|
||||
private readonly long _periodMs;
|
||||
private readonly ConcurrentDictionary<string, byte[]> _keys = new();
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, long> _seenNonces = [];
|
||||
private readonly Dictionary<string, (long WindowStartMs, int Count)> _sources = [];
|
||||
private long _lastSweepMs;
|
||||
|
||||
public KnockGate(NodeIdentity own, Func<IReadOnlyList<byte[]>> peers, TimeProvider time, int attemptsPerPeriod = 300, TimeSpan? period = null)
|
||||
{
|
||||
_own = own;
|
||||
_peers = peers;
|
||||
_time = time;
|
||||
_attemptsPerPeriod = attemptsPerPeriod;
|
||||
_periodMs = (long)(period ?? TimeSpan.FromSeconds(10)).TotalMilliseconds;
|
||||
}
|
||||
|
||||
/// <summary>读 32 字节并核对。通过返回 true,流从敲门包之后继续(TLS 或明文首帧);不通过返回 false,调用方直接关闭连接,不要写任何东西。</summary>
|
||||
public async ValueTask<bool> AdmitAsync(Stream stream, IPAddress? source, CancellationToken ct)
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
if (!TryCountSource(source, now))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] packet = new byte[Knock.PacketBytes];
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(s_readTimeout);
|
||||
try
|
||||
{
|
||||
await stream.ReadExactlyAsync(packet, timeout.Token);
|
||||
}
|
||||
catch (Exception ex) when (ex is EndOfStreamException or OperationCanceledException or IOException or ObjectDisposedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Accept(packet, now);
|
||||
}
|
||||
|
||||
/// <summary>核对一个敲门包(不读流):名册里有成员的密钥对得上,并且 nonce 没用过。</summary>
|
||||
public bool Accept(ReadOnlySpan<byte> packet, long nowMs)
|
||||
{
|
||||
bool matched = false;
|
||||
foreach (byte[] peer in _peers())
|
||||
{
|
||||
byte[] key = _keys.GetOrAdd(Convert.ToBase64String(peer), _ => Knock.DeriveKey(_own, peer));
|
||||
matched |= Knock.Check(key, packet, nowMs);
|
||||
}
|
||||
|
||||
if (!matched)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string nonce = Convert.ToHexString(packet[..Knock.NonceBytes]);
|
||||
lock (_gate)
|
||||
{
|
||||
Sweep(nowMs);
|
||||
return _seenNonces.TryAdd(nonce, nowMs + (Knock.WindowSeconds * 3 * 1000L));
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryCountSource(IPAddress? source, long nowMs)
|
||||
{
|
||||
string key = source is null ? "" : source.IsIPv4MappedToIPv6 ? source.MapToIPv4().ToString() : source.ToString();
|
||||
lock (_gate)
|
||||
{
|
||||
Sweep(nowMs);
|
||||
(long start, int count) = _sources.TryGetValue(key, out var entry) && nowMs - entry.WindowStartMs < _periodMs ? entry : (nowMs, 0);
|
||||
_sources[key] = (start, count + 1);
|
||||
return count < _attemptsPerPeriod;
|
||||
}
|
||||
}
|
||||
|
||||
private void Sweep(long nowMs)
|
||||
{
|
||||
if (nowMs - _lastSweepMs < 5000 && _seenNonces.Count < 4096 && _sources.Count < 4096)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastSweepMs = nowMs;
|
||||
foreach (string nonce in _seenNonces.Where(p => p.Value <= nowMs).Select(p => p.Key).ToList())
|
||||
{
|
||||
_seenNonces.Remove(nonce);
|
||||
}
|
||||
|
||||
foreach (string source in _sources.Where(p => nowMs - p.Value.WindowStartMs >= _periodMs).Select(p => p.Key).ToList())
|
||||
{
|
||||
_sources.Remove(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,16 @@ public sealed class NodeIdentity : IDisposable
|
||||
|
||||
public byte[] ExportPkcs8() => _key.ExportPkcs8PrivateKey();
|
||||
|
||||
/// <summary>与对端公钥(SPKI)做静态 ECDH,返回原始共享秘密。只用于派生对称密钥(敲门),必须再经 KDF,不能直接当密钥用。</summary>
|
||||
public byte[] AgreeWith(ReadOnlySpan<byte> peerSpki)
|
||||
{
|
||||
using ECDiffieHellman own = ECDiffieHellman.Create();
|
||||
own.ImportPkcs8PrivateKey(_key.ExportPkcs8PrivateKey(), out _);
|
||||
using ECDiffieHellman peer = ECDiffieHellman.Create();
|
||||
peer.ImportSubjectPublicKeyInfo(peerSpki, out _);
|
||||
return own.DeriveRawSecretAgreement(peer.PublicKey);
|
||||
}
|
||||
|
||||
public byte[] Sign(string purpose, params string[] fields) =>
|
||||
_key.SignData(Encoding.UTF8.GetBytes(SignatureString.Build(purpose, fields)), HashAlgorithmName.SHA256,
|
||||
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||
|
||||
@@ -22,6 +22,9 @@ public sealed class PeerListenerOptions
|
||||
/// <summary>按对端 ID 查找允许连接的公钥(SPKI)。不在列表里的对端一律拒绝(期望状态里的 peers)。</summary>
|
||||
public required Func<string, byte[]?> LookupPeerKey { get; init; }
|
||||
|
||||
/// <summary>预握手敲门:每个连接先过这一关(名册里的 Probe 才算得出敲门包),不通过就直接断开,什么都不写。</summary>
|
||||
public required KnockGate Knock { get; init; }
|
||||
|
||||
public int MaxFrameBytes { get; init; } = 8 << 20;
|
||||
|
||||
public int MaxSessions { get; init; } = 8;
|
||||
@@ -98,7 +101,13 @@ public sealed class PeerListener
|
||||
PeerSockets.EnableKeepAlive(client.Client);
|
||||
using var timeout = new CancellationTokenSource(_options.HandshakeTimeout, _time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
var tls = new SslStream(client.GetStream(), leaveInnerStreamOpen: false);
|
||||
Stream stream = client.GetStream();
|
||||
if (!await _options.Knock.AdmitAsync(stream, (client.Client.RemoteEndPoint as IPEndPoint)?.Address, linked.Token))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var tls = new SslStream(stream, leaveInnerStreamOpen: false);
|
||||
await tls.AuthenticateAsServerAsync(
|
||||
new SslServerAuthenticationOptions { ServerCertificate = _options.Certificate, ClientCertificateRequired = false, EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13 },
|
||||
linked.Token);
|
||||
@@ -222,8 +231,10 @@ internal static class PeerSockets
|
||||
public static class PeerConnector
|
||||
{
|
||||
public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync(
|
||||
string host, int port, byte[] expectedSpkiSha256, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct)
|
||||
string host, int port, byte[] agentPublicKey, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct)
|
||||
{
|
||||
// 证书按公钥的指纹固定;敲门密钥由双方身份密钥派生
|
||||
byte[] expectedSpkiSha256 = NodeIdentity.Fingerprint(agentPublicKey);
|
||||
TcpClient client = IPAddress.TryParse(host, out IPAddress? address) ? new TcpClient(address.AddressFamily) : new TcpClient();
|
||||
client.NoDelay = true;
|
||||
PeerSockets.EnableKeepAlive(client.Client);
|
||||
@@ -232,8 +243,10 @@ public static class PeerConnector
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
await client.ConnectAsync(host, port, linked.Token);
|
||||
Stream stream = client.GetStream();
|
||||
await stream.WriteAsync(Knock.Build(Knock.DeriveKey(identity, agentPublicKey), time.GetUtcNow().ToUnixTimeMilliseconds()), linked.Token);
|
||||
var tls = new SslStream(
|
||||
client.GetStream(), leaveInnerStreamOpen: false,
|
||||
stream, leaveInnerStreamOpen: false,
|
||||
(_, certificate, _, _) => certificate is not null && SpkiMatches(certificate, expectedSpkiSha256));
|
||||
await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = host }, linked.Token);
|
||||
var transport = new TlsFrameTransport(tls, maxFrameBytes);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -280,12 +280,20 @@ public sealed class LinkTests
|
||||
private sealed class PeerFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _stop = new();
|
||||
private readonly NodeIdentity _identity;
|
||||
|
||||
public PeerFixture(Func<string, byte[]?> lookup, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task>? onSession = null)
|
||||
{
|
||||
Certificate = NodeCertificate.LoadPfx(NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System));
|
||||
_identity = NodeIdentity.FromPkcs8(Certificate.GetECDsaPrivateKey()!.ExportPkcs8PrivateKey());
|
||||
Listener = new PeerListener(
|
||||
new PeerListenerOptions { Listen = new IPEndPoint(IPAddress.Loopback, 0), Certificate = Certificate, LookupPeerKey = lookup },
|
||||
new PeerListenerOptions
|
||||
{
|
||||
Listen = new IPEndPoint(IPAddress.Loopback, 0),
|
||||
Certificate = Certificate,
|
||||
LookupPeerKey = lookup,
|
||||
Knock = new KnockGate(_identity, () => KnockPeers, TimeProvider.System),
|
||||
},
|
||||
onSession ?? ((_, transport, ct) => KeepOpenAsync(transport, ct)),
|
||||
NullNodeLog.Instance, TimeProvider.System);
|
||||
Task = Listener.RunAsync(_stop.Token);
|
||||
@@ -299,10 +307,17 @@ public sealed class LinkTests
|
||||
|
||||
public byte[] Spki => NodeCertificate.SpkiFingerprint(Certificate);
|
||||
|
||||
/// <summary>Agent 的 TLS 证书公钥:Probe 用它固定证书、派生敲门密钥。</summary>
|
||||
public byte[] PublicKey => Certificate.PublicKey.ExportSubjectPublicKeyInfo();
|
||||
|
||||
/// <summary>名册:能敲开门的 Probe 公钥。</summary>
|
||||
public List<byte[]> KnockPeers { get; } = [];
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _stop.CancelAsync();
|
||||
await Task;
|
||||
_identity.Dispose();
|
||||
Certificate.Dispose();
|
||||
_stop.Dispose();
|
||||
}
|
||||
@@ -325,9 +340,10 @@ public sealed class LinkTests
|
||||
seen.TrySetResult(info);
|
||||
return Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, CancellationToken.None);
|
||||
});
|
||||
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
(PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
(PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await using (transport)
|
||||
{
|
||||
PeerSessionInfo server = await seen.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
@@ -343,17 +359,18 @@ public sealed class LinkTests
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
using NodeIdentity impostor = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null);
|
||||
agent.KnockPeers.AddRange([probe.PublicKeySpki, impostor.PublicKeySpki]); // 敲门通过之后,名册外的 ID 和伪造的签名仍然被拒绝
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
InvalidOperationException unknown = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, default));
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, default));
|
||||
InvalidOperationException forged = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, impostor, "prb_1", null, 1 << 20, TimeProvider.System, default));
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, impostor, "prb_1", null, 1 << 20, TimeProvider.System, default));
|
||||
Assert.Equal(unknown.Message, forged.Message);
|
||||
Assert.Contains("fleet_unauthorized", forged.Message);
|
||||
|
||||
// 正主仍然可以连接
|
||||
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await ok.DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -362,11 +379,13 @@ public sealed class LinkTests
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
|
||||
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
byte[] wrong = SHA256.HashData("another certificate"u8);
|
||||
await Assert.ThrowsAnyAsync<AuthenticationException>(async () =>
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, wrong, probe, "prb_1", null, 1 << 20, TimeProvider.System, default));
|
||||
// 公钥不对:敲门密钥对不上,Agent 直接断开(拿到的不是证书,连不是它的证书都看不到)
|
||||
using NodeIdentity another = NodeIdentity.Generate();
|
||||
await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, another.PublicKeySpki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -382,9 +401,10 @@ public sealed class LinkTests
|
||||
await using var session = new RpcSession<string>(transport, router, info.PeerId, TimeProvider.System);
|
||||
await session.RunAsync(ct);
|
||||
});
|
||||
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(RpcSide.Dialer, table), "probe", TimeProvider.System);
|
||||
Task run = client.RunAsync(default);
|
||||
Result<Number> result = await client.RequestAsync("agent.echo", new Number { Value = 41 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
|
||||
@@ -398,6 +418,7 @@ public sealed class LinkTests
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
|
||||
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
// 不说 TLS 的连接:握手失败,监听继续
|
||||
@@ -407,7 +428,45 @@ public sealed class LinkTests
|
||||
await garbage.GetStream().WriteAsync("GET / HTTP/1.1\r\n\r\n"u8.ToArray());
|
||||
}
|
||||
|
||||
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await ok.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PeerListener_WritesNothingToStrangers_NotEvenATlsServerHello()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
|
||||
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
// 一个像样的 TLS ClientHello 开头、随机数据、什么都不发:都拿不到任何字节(连接被直接关闭)
|
||||
foreach (byte[] payload in new[] { new byte[] { 0x16, 0x03, 0x01, 0x00, 0x05, 0x01, 0x00, 0x00, 0x01, 0x00 }.Concat(RandomNumberGenerator.GetBytes(64)).ToArray(), RandomNumberGenerator.GetBytes(32), [] })
|
||||
{
|
||||
using var scanner = new TcpClient();
|
||||
await scanner.ConnectAsync(IPAddress.Loopback, agent.Listener.Port);
|
||||
NetworkStream stream = scanner.GetStream();
|
||||
if (payload.Length > 0)
|
||||
{
|
||||
await stream.WriteAsync(payload);
|
||||
}
|
||||
|
||||
byte[] buffer = new byte[64];
|
||||
int read;
|
||||
try
|
||||
{
|
||||
read = await stream.ReadAsync(buffer).AsTask().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
read = 0; // 连接被重置也是「什么都没收到」
|
||||
}
|
||||
|
||||
Assert.Equal(0, read);
|
||||
}
|
||||
|
||||
// 名册里的 Probe 仍然进得来
|
||||
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await ok.DisposeAsync();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user