Skip to content

Commit af53143

Browse files
committed
encryption for direct connect
1 parent 6148a81 commit af53143

30 files changed

Lines changed: 1240 additions & 5095 deletions

Basis Server/BasisNetworkCore/BasisNetworkCore.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@
1616
<ItemGroup>
1717
<PackageReference Include="System.Threading.Tasks" Version="4.3.0" />
1818
<ProjectReference Include="..\LiteNetLib\LiteNetLib.csproj" />
19+
<ProjectReference Include="..\Contrib\Crypto\Crypto.csproj" />
1920
</ItemGroup>
2021
</Project>
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
#nullable enable
2+
3+
using System;
4+
using System.Text;
5+
using Basis.Contrib.Crypto;
6+
7+
namespace Basis.Network.Core
8+
{
9+
/// X25519 + HKDF-SHA256 key agreement for the encrypted peer-to-peer (direct) link.
10+
/// The two peers exchange ephemeral public keys through the server's signalling
11+
/// channel and each derives the same two directional keys, because ECDH(myPriv,
12+
/// peerPub) is symmetric; the transcript (both public keys) is folded into the HKDF
13+
/// salt for channel binding.
14+
public static class BasisCryptoHandshake
15+
{
16+
public const int PublicKeySize = BasisX25519.KeySize;
17+
public const int PrivateKeySize = BasisX25519.KeySize;
18+
public const int KeySize = BasisAeadCipher.KeySize;
19+
20+
private static readonly byte[] InfoAB = Encoding.ASCII.GetBytes("basis-crypto-v1-ab");
21+
private static readonly byte[] InfoBA = Encoding.ASCII.GetBytes("basis-crypto-v1-ba");
22+
23+
public static void GenerateKeyPair(out byte[] privateKey, out byte[] publicKey)
24+
=> BasisX25519.GenerateKeyPair(out privateKey, out publicKey);
25+
26+
/// Derives the directional keys for a peer-to-peer link. Role is decided by
27+
/// public-key ordering so both ends agree without extra signalling.
28+
public static bool DerivePeerKeys(
29+
ReadOnlySpan<byte> myPrivate,
30+
ReadOnlySpan<byte> myPublic,
31+
ReadOnlySpan<byte> peerPublic,
32+
out byte[] sendKey,
33+
out byte[] recvKey)
34+
{
35+
sendKey = Array.Empty<byte>();
36+
recvKey = Array.Empty<byte>();
37+
try
38+
{
39+
int cmp = Compare(myPublic, peerPublic);
40+
if (cmp == 0) return false;
41+
bool iAmA = cmp < 0;
42+
43+
ReadOnlySpan<byte> aPub = iAmA ? myPublic : peerPublic;
44+
ReadOnlySpan<byte> bPub = iAmA ? peerPublic : myPublic;
45+
46+
byte[] shared = BasisX25519.Agree(myPrivate, peerPublic);
47+
byte[] salt = Concat(aPub, bPub);
48+
byte[] keyAB = BasisHkdf.DeriveKey(shared, salt, InfoAB, KeySize);
49+
byte[] keyBA = BasisHkdf.DeriveKey(shared, salt, InfoBA, KeySize);
50+
51+
if (iAmA)
52+
{
53+
sendKey = keyAB;
54+
recvKey = keyBA;
55+
}
56+
else
57+
{
58+
sendKey = keyBA;
59+
recvKey = keyAB;
60+
}
61+
return true;
62+
}
63+
catch
64+
{
65+
return false;
66+
}
67+
}
68+
69+
private static byte[] Concat(ReadOnlySpan<byte> a, ReadOnlySpan<byte> b)
70+
{
71+
var result = new byte[a.Length + b.Length];
72+
a.CopyTo(result);
73+
b.CopyTo(result.AsSpan(a.Length));
74+
return result;
75+
}
76+
77+
private static int Compare(ReadOnlySpan<byte> a, ReadOnlySpan<byte> b)
78+
{
79+
int n = Math.Min(a.Length, b.Length);
80+
for (int i = 0; i < n; i++)
81+
{
82+
int d = a[i] - b[i];
83+
if (d != 0) return d;
84+
}
85+
return a.Length - b.Length;
86+
}
87+
}
88+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
#nullable enable
2+
3+
using System;
4+
using System.Collections.Concurrent;
5+
using System.Collections.Generic;
6+
using System.Net;
7+
using System.Threading;
8+
using Basis.Contrib.Crypto;
9+
using LiteNetLib.Layers;
10+
11+
namespace Basis.Network.Core
12+
{
13+
/// Per-endpoint AEAD encryption applied at the LiteNetLib socket boundary.
14+
/// Each connection has its own pair of ChaCha20-Poly1305 keys (one per
15+
/// direction) established by an X25519 handshake; see <see cref="BasisCryptoHandshake"/>.
16+
///
17+
/// Only the user-data-bearing packet properties are encrypted (Unreliable,
18+
/// Channeled, Merged). Connection setup, NAT, MTU and out-of-band probe packets
19+
/// stay cleartext so the handshake itself never depends on a key being present.
20+
///
21+
/// Wire layout of an encrypted datagram:
22+
/// [byte 0 : LiteNetLib header (cleartext, authenticated as AAD)]
23+
/// [bytes 1..n : ciphertext]
24+
/// [16 bytes : Poly1305 tag]
25+
/// [8 bytes : little-endian nonce counter]
26+
public sealed class BasisCryptoLayer : PacketLayerBase
27+
{
28+
public const int CounterSize = 8;
29+
public const int Overhead = BasisAeadCipher.TagSize + CounterSize;
30+
31+
private const byte PropertyMask = 0x1F;
32+
// Mirrors LiteNetLib.PacketProperty: Unreliable = 0, Channeled = 1, Merged = 12.
33+
private const byte PropUnreliable = 0;
34+
private const byte PropChanneled = 1;
35+
private const byte PropMerged = 12;
36+
37+
private sealed class Session
38+
{
39+
public BasisAeadCipher Send = null!;
40+
public BasisAeadCipher Recv = null!;
41+
public long SendCounter;
42+
}
43+
44+
// Keyed by address+port only. NetPeer (seen outbound) and the plain IPEndPoint seen
45+
// inbound / at install hash differently under non-native sockets; this comparer makes
46+
// all three resolve to the same session without allocating per packet.
47+
private readonly ConcurrentDictionary<IPEndPoint, Session> _sessions
48+
= new ConcurrentDictionary<IPEndPoint, Session>(EndpointComparer.Instance);
49+
50+
public BasisCryptoLayer() : base(Overhead) { }
51+
52+
private sealed class EndpointComparer : IEqualityComparer<IPEndPoint>
53+
{
54+
public static readonly EndpointComparer Instance = new EndpointComparer();
55+
56+
public bool Equals(IPEndPoint x, IPEndPoint y)
57+
{
58+
if (ReferenceEquals(x, y)) return true;
59+
if (x is null || y is null) return false;
60+
return x.Port == y.Port && x.Address.Equals(y.Address);
61+
}
62+
63+
public int GetHashCode(IPEndPoint ep)
64+
{
65+
if (ep is null) return 0;
66+
unchecked { return (ep.Address.GetHashCode() * 397) ^ ep.Port; }
67+
}
68+
}
69+
70+
public int SessionCount => _sessions.Count;
71+
72+
/// <param name="initialSendCounter">
73+
/// First nonce counter to use. Pass a value strictly greater than any counter
74+
/// previously used with these keys when re-installing the same keys for a
75+
/// reconnect, so a (key, nonce) pair is never reused.
76+
/// </param>
77+
public void SetEndpointKeys(IPEndPoint endpoint, byte[] sendKey, byte[] recvKey, long initialSendCounter = 0)
78+
{
79+
if (endpoint == null) return;
80+
var session = new Session
81+
{
82+
Send = new BasisAeadCipher(sendKey),
83+
Recv = new BasisAeadCipher(recvKey),
84+
SendCounter = initialSendCounter
85+
};
86+
if (_sessions.TryRemove(endpoint, out var old)) DisposeSession(old);
87+
_sessions[endpoint] = session;
88+
}
89+
90+
public bool HasEndpoint(IPEndPoint endpoint) => endpoint != null && _sessions.ContainsKey(endpoint);
91+
92+
public void RemoveEndpoint(IPEndPoint endpoint)
93+
{
94+
if (endpoint != null && _sessions.TryRemove(endpoint, out var session)) DisposeSession(session);
95+
}
96+
97+
public void RemapEndpoint(IPEndPoint oldEndpoint, IPEndPoint newEndpoint)
98+
{
99+
if (oldEndpoint == null || newEndpoint == null) return;
100+
if (_sessions.TryRemove(oldEndpoint, out var session)) _sessions[newEndpoint] = session;
101+
}
102+
103+
public override void ProcessOutBoundPacket(ref IPEndPoint endPoint, ref byte[] data, ref int offset, ref int length)
104+
{
105+
if (length < 1) return;
106+
byte header = data[offset];
107+
if (!IsEncryptable((byte)(header & PropertyMask))) return;
108+
if (endPoint == null || !_sessions.TryGetValue(endPoint, out var session)) return;
109+
110+
long counter = Interlocked.Increment(ref session.SendCounter);
111+
Span<byte> nonce = stackalloc byte[BasisAeadCipher.NonceSize];
112+
WriteCounter(nonce, counter);
113+
114+
int tagOffset = offset + length;
115+
session.Send.Seal(nonce, header, data, offset + 1, length - 1, data, tagOffset);
116+
WriteCounterBytes(data, tagOffset + BasisAeadCipher.TagSize, counter);
117+
length += Overhead;
118+
}
119+
120+
public override void ProcessInboundPacket(ref IPEndPoint endPoint, ref byte[] data, ref int length)
121+
{
122+
if (length < 1) return;
123+
byte header = data[0];
124+
if (!IsEncryptable((byte)(header & PropertyMask))) return;
125+
if (endPoint == null || !_sessions.TryGetValue(endPoint, out var session)) return;
126+
127+
if (length < 1 + Overhead)
128+
{
129+
length = 0;
130+
return;
131+
}
132+
133+
int tagOffset = length - Overhead;
134+
int counterOffset = length - CounterSize;
135+
long counter = ReadCounterBytes(data, counterOffset);
136+
Span<byte> nonce = stackalloc byte[BasisAeadCipher.NonceSize];
137+
WriteCounter(nonce, counter);
138+
139+
int payloadLength = tagOffset - 1;
140+
if (!session.Recv.Open(nonce, header, data, 1, payloadLength, data, tagOffset))
141+
{
142+
length = 0;
143+
return;
144+
}
145+
length -= Overhead;
146+
}
147+
148+
private static bool IsEncryptable(byte property)
149+
=> property == PropUnreliable || property == PropChanneled || property == PropMerged;
150+
151+
private static void DisposeSession(Session session)
152+
{
153+
session.Send.Dispose();
154+
session.Recv.Dispose();
155+
}
156+
157+
private static void WriteCounter(Span<byte> nonce, long counter)
158+
{
159+
nonce.Clear();
160+
ulong c = (ulong)counter;
161+
nonce[0] = (byte)c;
162+
nonce[1] = (byte)(c >> 8);
163+
nonce[2] = (byte)(c >> 16);
164+
nonce[3] = (byte)(c >> 24);
165+
nonce[4] = (byte)(c >> 32);
166+
nonce[5] = (byte)(c >> 40);
167+
nonce[6] = (byte)(c >> 48);
168+
nonce[7] = (byte)(c >> 56);
169+
}
170+
171+
private static void WriteCounterBytes(byte[] buffer, int offset, long counter)
172+
{
173+
ulong c = (ulong)counter;
174+
buffer[offset] = (byte)c;
175+
buffer[offset + 1] = (byte)(c >> 8);
176+
buffer[offset + 2] = (byte)(c >> 16);
177+
buffer[offset + 3] = (byte)(c >> 24);
178+
buffer[offset + 4] = (byte)(c >> 32);
179+
buffer[offset + 5] = (byte)(c >> 40);
180+
buffer[offset + 6] = (byte)(c >> 48);
181+
buffer[offset + 7] = (byte)(c >> 56);
182+
}
183+
184+
private static long ReadCounterBytes(byte[] buffer, int offset)
185+
{
186+
ulong c = buffer[offset]
187+
| ((ulong)buffer[offset + 1] << 8)
188+
| ((ulong)buffer[offset + 2] << 16)
189+
| ((ulong)buffer[offset + 3] << 24)
190+
| ((ulong)buffer[offset + 4] << 32)
191+
| ((ulong)buffer[offset + 5] << 40)
192+
| ((ulong)buffer[offset + 6] << 48)
193+
| ((ulong)buffer[offset + 7] << 56);
194+
return (long)c;
195+
}
196+
}
197+
}

Basis Server/BasisNetworkCore/Serializable/BasisP2PMessages.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,45 @@ public static partial class SerializableBasis
55
public struct BasisP2PSignalMessage
66
{
77
public const int MaxTokenLength = 64;
8+
public const int PublicKeySize = 32;
89

910
public ushort otherPlayerId;
1011
public string sessionToken;
12+
/// <summary>
13+
/// X25519 ephemeral public key of the sender, relayed by the server so the two
14+
/// peers can derive a per-pair key and always encrypt the direct (P2P) link.
15+
/// </summary>
16+
public byte[] ephemeralPublicKey;
1117

1218
public void Deserialize(NetDataReader reader)
1319
{
1420
otherPlayerId = reader.GetUShort();
1521
sessionToken = reader.GetString(MaxTokenLength);
22+
byte hasKey = reader.GetByte();
23+
if (hasKey == 1 && reader.AvailableBytes >= PublicKeySize)
24+
{
25+
ephemeralPublicKey = new byte[PublicKeySize];
26+
reader.GetBytes(ephemeralPublicKey, PublicKeySize);
27+
}
28+
else
29+
{
30+
ephemeralPublicKey = null;
31+
}
1632
}
1733

1834
public void Serialize(NetDataWriter writer)
1935
{
2036
writer.Put(otherPlayerId);
2137
writer.Put(sessionToken ?? string.Empty, MaxTokenLength);
38+
if (ephemeralPublicKey != null && ephemeralPublicKey.Length == PublicKeySize)
39+
{
40+
writer.Put((byte)1);
41+
writer.Put(ephemeralPublicKey);
42+
}
43+
else
44+
{
45+
writer.Put((byte)0);
46+
}
2247
}
2348
}
2449
}

Basis Server/BasisNetworkServer/BasisServerP2PBroker.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ private static void HandleRequest(NetPeer sender, BasisP2PSignalMessage msg)
151151
TrackPeerSession(msg.otherPlayerId, msg.sessionToken);
152152

153153
BNL.Log($"[P2P] Forwarding Request from peer {sender.Id} to peer {msg.otherPlayerId} (token {msg.sessionToken}).");
154-
SendSub(target, BasisNetworkCommons.P2PSub_Request, msg.sessionToken, (ushort)sender.Id);
154+
SendSub(target, BasisNetworkCommons.P2PSub_Request, msg.sessionToken, (ushort)sender.Id, msg.ephemeralPublicKey);
155155

156156
// ServerArmed confirms registration before either side starts punching, avoiding a race.
157157
SendSub(sender, BasisNetworkCommons.P2PSub_ServerArmed, msg.sessionToken, msg.otherPlayerId);
@@ -175,7 +175,7 @@ private static void HandleAccept(NetPeer sender, BasisP2PSignalMessage msg)
175175
if (NetworkServer.AuthenticatedPeers.TryGetValue(s.InitiatorPeerId, out NetPeer initiator))
176176
{
177177
BNL.Log($"[P2P] Accept from peer {sender.Id} (token {Preview(s.Token)}); session armed, forwarding to initiator {s.InitiatorPeerId}.");
178-
SendSub(initiator, BasisNetworkCommons.P2PSub_Accept, s.Token, (ushort)sender.Id);
178+
SendSub(initiator, BasisNetworkCommons.P2PSub_Accept, s.Token, (ushort)sender.Id, msg.ephemeralPublicKey);
179179
}
180180
else
181181
{
@@ -310,14 +310,15 @@ private static void UntrackPeerSession(int peerId, string token)
310310
}
311311
}
312312

313-
private static void SendSub(NetPeer to, byte sub, string token, ushort otherPlayerId)
313+
private static void SendSub(NetPeer to, byte sub, string token, ushort otherPlayerId, byte[] ephemeralPublicKey = null)
314314
{
315315
NetDataWriter writer = NetworkServer.RentWriter();
316316
writer.Put(sub);
317317
var body = new BasisP2PSignalMessage
318318
{
319319
otherPlayerId = otherPlayerId,
320320
sessionToken = token ?? string.Empty,
321+
ephemeralPublicKey = ephemeralPublicKey,
321322
};
322323
body.Serialize(writer);
323324
NetworkServer.TrySend(to, writer, BasisNetworkCommons.P2PChannel, DeliveryMethod.ReliableOrdered);

0 commit comments

Comments
 (0)