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);
|
||||
|
||||
Reference in new issue
Block a user