81 lines
3.3 KiB
C#
81 lines
3.3 KiB
C#
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);
|
||
}
|
||
}
|