Files
RobustToolbox/Robust.Shared/Serialization/RobustMappedStringSerializer.cs
T
Pieter-Jan BriersandGitHub aa64528a03 Auth (#1289)
* Some stuff for auth

* Holy crap auth works

* Enable encryption even if no auth token is provided.

It's still possible that the public key was retrieved over HTTPS via the status API, in which case it will be secure.

* Fix integration test compile.

* Secure CVar API.

* Literally rewrite the auth protocol to be minecraft's.

* Better exception tolerance in server handshake.

* Auth works from launcher.

* Fix some usages of UserID instead of UserName

* Fix auth.server CVar

* Kick existing connection if same account connects twice.

* Username assignment, guest session distinguishing.

* Necessary work to make bans work.

* Expose LoginType to OnConnecting.

* Fixing tests and warnings.
2020-09-29 14:18:12 +02:00

661 lines
25 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using NetSerializer;
using Newtonsoft.Json.Linq;
using Robust.Shared.ContentPack;
using Robust.Shared.Interfaces.Log;
using Robust.Shared.Interfaces.Network;
using Robust.Shared.IoC;
using Robust.Shared.Log;
using Robust.Shared.Network;
using Robust.Shared.Network.Messages;
using Robust.Shared.Utility;
using YamlDotNet.RepresentationModel;
using static Robust.Shared.Utility.Base64Helpers;
namespace Robust.Shared.Serialization
{
/// <summary>
/// Serializer which manages a mapping of pre-loaded strings to constant
/// values, for message compression. The mapping is shared between the
/// server and client.
/// </summary>
/// <remarks>
/// Strings are long and expensive to send over the wire, and lots of
/// strings involved in messages are sent repeatedly between the server
/// and client - such as filenames, icon states, constant strings, etc.
///
/// To compress these strings, we use a constant string mapping, decided
/// by the server when it starts up, that associates strings with a
/// fixed value. The mapping is shared with clients when they connect.
///
/// When sending these strings over the wire, the serializer can then
/// send the constant value instead - and at the other end, the
/// serializer can use the same mapping to recover the original string.
/// </remarks>
internal sealed partial class RobustMappedStringSerializer : IStaticTypeSerializer, IRobustMappedStringSerializer
{
private delegate void WriteStringDelegate(Stream stream, string? value);
private delegate void ReadStringDelegate(Stream stream, out string? value);
private static MethodInfo WriteMappedStringMethodInfo
=> ((WriteStringDelegate) StaticWriteMappedString).Method;
private static MethodInfo ReadMappedStringMethodInfo
=> ((ReadStringDelegate) StaticReadMappedString).Method;
private static readonly char[] TrimmableSymbolChars =
{
'.', '\\', '/', ',', '#', '$', '?', '!', '@', '|', '&',
'*', '(', ')', '^', '`', '"', '\'', '`', '~', '[', ']',
'{', '}', ':', ';', '-'
};
private static readonly HashAlgorithmName PackHashAlgo = HashAlgorithmName.SHA512;
/// <summary>
/// The shortest a string can be in order to be inserted in the mapping.
/// </summary>
/// <remarks>
/// Strings below a certain length aren't worth compressing.
/// </remarks>
private const int MinMappedStringSize = 3;
/// <summary>
/// The longest a string can be in order to be inserted in the mapping.
/// </summary>
private const int MaxMappedStringSize = 420;
/// <summary>
/// The special value corresponding to a <c>null</c> string in the
/// encoding.
/// </summary>
private const uint MappedNull = 0;
/// <summary>
/// The special value corresponding to a string which was not mapped.
/// This is followed by the bytes of the unmapped string.
/// </summary>
private const uint UnmappedString = 1;
/// <summary>
/// The first non-special value, used for encoding mapped strings.
/// </summary>
/// <remarks>
/// Since previous values are taken by <see cref="MappedNull"/> and
/// <see cref="UnmappedString"/>, this value is used to encode
/// mapped strings at an offset - in the encoding, a value
/// <c>>= FirstMappedIndexStart</c> represents the string with
/// mapping of that value <c> - FirstMappedIndexStart</c>.
/// </remarks>
private const uint FirstMappedIndexStart = 2;
[Dependency] private readonly INetManager _net = default!;
// I don't want to create 50 line changes in this commit so...
// ReSharper disable once InconsistentNaming
private ISawmill LogSzr = default!;
private MappedStringDict _dict = default!;
private readonly Dictionary<INetChannel, InProgressHandshake> _incompleteHandshakes
= new Dictionary<INetChannel, InProgressHandshake>();
private byte[]? _mappedStringsPackage;
private byte[]? _serverHash;
private byte[]? _stringMapHash;
/// <value>
/// The hash of the string mapping.
/// </value>
/// <exception cref="InvalidOperationException">
/// Thrown if the mapping is not locked.
/// </exception>
public ReadOnlySpan<byte> MappedStringsHash => _stringMapHash;
private static readonly Regex RxSymbolSplitter
= new Regex(
@"(?<=[^\s\W])(?=[A-Z]) # Match for split at start of new capital letter
|(?<=[^0-9\s\W])(?=[0-9]) # Match for split before spans of numbers
|(?<=[A-Za-z0-9])(?=_) # Match for a split before an underscore
|(?=[.\\\/,#$?!@|&*()^`""'`~[\]{}:;\-]) # Match for a split after symbols
|(?<=[.\\\/,#$?!@|&*()^`""'`~[\]{}:;\-]) # Match for a split before symbols too",
RegexOptions.CultureInvariant
| RegexOptions.Compiled
| RegexOptions.IgnorePatternWhitespace
);
public ITypeSerializer TypeSerializer => this;
/// <summary>
/// Starts the handshake from the server end of the given channel,
/// sending a <see cref="MsgMapStrServerHandshake"/>.
/// </summary>
/// <param name="channel">The network channel to perform the handshake over.</param>
/// <remarks>
/// Locks the string mapping if this is the first time the server is
/// performing the handshake.
/// </remarks>
/// <seealso cref="MsgMapStrClientHandshake"/>
/// <seealso cref="MsgMapStrStrings"/>
public Task Handshake(INetChannel channel)
{
DebugTools.Assert(_net.IsServer);
DebugTools.Assert(_dict.Locked);
var tcs = new TaskCompletionSource<object?>();
_incompleteHandshakes.Add(channel, new InProgressHandshake(tcs));
var message = _net.CreateNetMessage<MsgMapStrServerHandshake>();
message.Hash = _stringMapHash;
_net.ServerSendMessage(message, channel);
return tcs.Task;
}
/// <summary>
/// Performs the setup so that the serializer can perform the string-
/// exchange protocol.
/// </summary>
/// <remarks>
/// The string-exchange protocol is started by the server when the
/// client first connects. The server sends the client a hash of the
/// string mapping; the client checks that hash against any local
/// caches; and if necessary, the client requests a new copy of the
/// mapping from the server.
///
/// Uncached flow: <code>
/// Client | Server
/// | &lt;-------------- Hash |
/// | Need Strings ------&gt; |
/// | &lt;----------- Strings |
/// | Dont Need Strings -&gt; |
/// </code>
///
/// Cached flow: <code>
/// Client | Server
/// | &lt;-------------- Hash |
/// | Dont Need Strings -&gt; |
/// </code>
///
/// Verification failure flow: <code>
/// Client | Server
/// | &lt;-------------- Hash |
/// | Need Strings ------&gt; |
/// | &lt;----------- Strings |
/// + Hash Failed |
/// | Need Strings ------&gt; |
/// | &lt;----------- Strings |
/// | Dont Need Strings -&gt; |
/// </code>
///
/// NOTE: Verification failure flow is currently not implemented.
/// </remarks>
/// <param name="net">
/// The <see cref="INetManager"/> to perform the protocol steps over.
/// </param>
/// <seealso cref="MsgMapStrServerHandshake"/>
/// <seealso cref="MsgMapStrClientHandshake"/>
/// <seealso cref="MsgMapStrStrings"/>
/// <seealso cref="HandleServerHandshake"/>
/// <seealso cref="HandleClientHandshake"/>
/// <seealso cref="HandleStringsMessage"/>
/// <seealso cref="OnClientCompleteHandshake"/>
private void NetworkInitialize()
{
_net.RegisterNetMessage<MsgMapStrServerHandshake>(nameof(MsgMapStrServerHandshake), HandleServerHandshake, NetMessageAccept.Client);
_net.RegisterNetMessage<MsgMapStrClientHandshake>(nameof(MsgMapStrClientHandshake), HandleClientHandshake, NetMessageAccept.Server);
_net.RegisterNetMessage<MsgMapStrStrings>(nameof(MsgMapStrStrings), HandleStringsMessage, NetMessageAccept.Client);
_net.Disconnect += NetOnDisconnect;
}
private void NetOnDisconnect(object? sender, NetDisconnectedArgs e)
{
// Cancel handshake in-progress if client disconnects mid-handshake.
var channel = e.Channel;
if (_incompleteHandshakes.TryGetValue(channel, out var handshake))
{
var tcs = handshake.Tcs;
LogSzr.Debug($"Cancelling handshake for disconnected client {channel.UserId}");
tcs.SetCanceled();
}
_incompleteHandshakes.Remove(channel);
}
/// <summary>
/// Handles the reception, verification of a strings package
/// and subsequent mapping of strings and initiator of
/// receipt response.
///
/// Uncached flow: <code>
/// Client | Server
/// | &lt;-------------- Hash |
/// | Need Strings ------&gt; |
/// | &lt;----------- Strings |
/// | Dont Need Strings -&gt; | &lt;- you are here on client
///
/// Verification failure flow: <code>
/// Client | Server
/// | &lt;-------------- Hash |
/// | Need Strings ------&gt; |
/// | &lt;----------- Strings |
/// + Hash Failed | &lt;- you are here on client
/// | Need Strings ------&gt; |
/// | &lt;----------- Strings |
/// | Dont Need Strings -&gt; | &lt;- you are here on client
/// </code>
///
/// NOTE: Verification failure flow is currently not implemented.
/// </code>
/// </summary>
/// <exception cref="InvalidOperationException">Unable to verify strings package by hash.</exception>
/// <seealso cref="NetworkInitialize"/>
private void HandleStringsMessage(MsgMapStrStrings msg)
{
DebugTools.Assert(_net.IsClient);
DebugTools.AssertNotNull(msg.Package);
DebugTools.AssertNotNull(_serverHash);
var packageStream = new MemoryStream(msg.Package!, false);
_dict.LoadFromPackage(packageStream, out var hash);
if (!hash.SequenceEqual(_serverHash!))
{
// TODO: retry sending MsgClientHandshake with NeedsStrings = false
throw new InvalidOperationException("Unable to verify strings package by hash." +
$"\n{ConvertToBase64Url(hash)} vs. {ConvertToBase64Url(_serverHash)}");
}
_stringMapHash = _serverHash;
LogSzr.Debug($"Locked in at {_dict.StringCount} mapped strings.");
packageStream.Position = 0;
WriteStringCache(packageStream);
// ok we're good now
var channel = msg.MsgChannel;
OnClientCompleteHandshake(_net, channel);
}
/// <summary>
/// Interpret a client's handshake, either sending a package
/// of strings or completing the handshake.
///
/// Uncached flow: <code>
/// Client | Server
/// | &lt;-------------- Hash |
/// | Need Strings ------&gt; | &lt;- you are here on server
/// | &lt;----------- Strings |
/// | Dont Need Strings -&gt; | &lt;- you are here on server
/// </code>
///
/// Cached flow: <code>
/// Client | Server
/// | &lt;-------------- Hash |
/// | Dont Need Strings -&gt; | &lt;- you are here on server
/// </code>
///
/// Verification failure flow: <code>
/// Client | Server
/// | &lt;-------------- Hash |
/// | Need Strings ------&gt; | &lt;- you are here on server
/// | &lt;----------- Strings |
/// + Hash Failed |
/// | Need Strings ------&gt; | &lt;- you are here on server
/// | &lt;----------- Strings |
/// | Dont Need Strings -&gt; |
/// </code>
///
/// NOTE: Verification failure flow is currently not implemented.
/// </summary>
/// <seealso cref="NetworkInitialize"/>
private void HandleClientHandshake(MsgMapStrClientHandshake msgMapStr)
{
DebugTools.Assert(_net.IsServer);
DebugTools.Assert(_dict.Locked);
var channel = msgMapStr.MsgChannel;
LogSzr.Debug($"Received handshake from {channel.UserName}.");
if (!_incompleteHandshakes.TryGetValue(channel, out var handshake))
{
channel.Disconnect("MsgMapStrClientHandshake without in-progress handshake.");
return;
}
if (!msgMapStr.NeedsStrings)
{
LogSzr.Debug($"Completing handshake with {channel.UserName}.");
handshake.Tcs.SetResult(null);
_incompleteHandshakes.Remove(channel);
return;
}
if (handshake.HasRequestedStrings)
{
channel.Disconnect("Cannot request strings twice");
return;
}
handshake.HasRequestedStrings = true;
var strings = _net.CreateNetMessage<MsgMapStrStrings>();
strings.Package = _mappedStringsPackage;
LogSzr.Debug(
$"Sending {_mappedStringsPackage!.Length} bytes sized mapped strings package to {channel.UserName}.");
_net.ServerSendMessage(strings, channel);
}
/// <summary>
/// Interpret a server's handshake, either requesting a package
/// of strings or completing the handshake.
///
/// Uncached flow: <code>
/// Client | Server
/// | &lt;-------------- Hash | &lt;- you are here on client
/// | Need Strings ------&gt; |
/// | &lt;----------- Strings |
/// | Dont Need Strings -&gt; |
/// </code>
///
/// Cached flow: <code>
/// Client | Server
/// | &lt;-------------- Hash | &lt;- you are here on client
/// | Dont Need Strings -&gt; |
/// </code>
///
/// Verification failure flow: <code>
/// Client | Server
/// | &lt;-------------- Hash | &lt;- you are here on client
/// | Need Strings ------&gt; |
/// | &lt;----------- Strings |
/// + Hash Failed |
/// | Need Strings ------&gt; |
/// | &lt;----------- Strings |
/// | Dont Need Strings -&gt; |
/// </code>
///
/// NOTE: Verification failure flow is currently not implemented.
/// </summary>
/// <exception cref="InvalidOperationException">Mapped strings are locked.</exception>
/// <seealso cref="NetworkInitialize"/>
private void HandleServerHandshake(MsgMapStrServerHandshake msgMapStr)
{
DebugTools.Assert(_net.IsClient);
_serverHash = msgMapStr.Hash;
var hashStr = ConvertToBase64Url(msgMapStr.Hash!);
LogSzr.Debug($"Received server handshake with hash {hashStr}.");
var fileName = CacheForHash(hashStr);
if (!File.Exists(fileName))
{
LogSzr.Debug($"No string cache for {hashStr}.");
var handshake = _net.CreateNetMessage<MsgMapStrClientHandshake>();
LogSzr.Debug("Asking server to send mapped strings.");
handshake.NeedsStrings = true;
msgMapStr.MsgChannel.SendMessage(handshake);
}
else
{
LogSzr.Debug($"We had a cached string map that matches {hashStr}.");
using var file = File.OpenRead(fileName);
var added = _dict.LoadFromPackage(file, out _);
_stringMapHash = msgMapStr.Hash!;
LogSzr.Debug($"Read {added} strings from cache {hashStr}.");
LogSzr.Debug($"Locked in at {_dict.StringCount} mapped strings.");
// ok we're good now
var channel = msgMapStr.MsgChannel;
OnClientCompleteHandshake(_net, channel);
}
}
/// <summary>
/// Inform the server that the client has a complete copy of the
/// mapping, and alert other code that the handshake is over.
/// </summary>
/// <seealso cref="ClientHandshakeComplete"/>
/// <seealso cref="NetworkInitialize"/>
private void OnClientCompleteHandshake(INetManager net, INetChannel channel)
{
LogSzr.Debug("Letting server know we're good to go.");
var handshake = net.CreateNetMessage<MsgMapStrClientHandshake>();
handshake.NeedsStrings = false;
channel.SendMessage(handshake);
if (ClientHandshakeComplete == null)
{
LogSzr.Warning("There's no handler attached to ClientHandshakeComplete.");
}
ClientHandshakeComplete?.Invoke();
}
/// <summary>
/// Gets the cache file associated with the given hash.
/// </summary>
/// <param name="hashStr">The hash to look up the cache for.</param>
/// <returns>
/// The filename where the cache for the given hash would be. The
/// file itself may or may not exist. If it does not exist, no cache
/// was made for the given hash.
/// </returns>
private static string CacheForHash(string hashStr)
{
return PathHelpers.ExecutableRelativeFile($"strings-{hashStr}");
}
/// <summary>
/// Saves the string cache to a file based on it's hash.
/// </summary>
private void WriteStringCache(Stream stream)
{
var hashStr = Convert.ToBase64String(MappedStringsHash);
hashStr = ConvertToBase64Url(hashStr);
var fileName = CacheForHash(hashStr);
using var file = File.OpenWrite(fileName);
stream.CopyTo(file);
LogSzr.Debug($"Wrote string cache {hashStr}.");
}
/// <summary>
/// Add a string to the constant mapping.
/// </summary>
/// <remarks>
/// If the string has multiple detectable subcomponents, such as a
/// filepath, it may result in more than one string being added to
/// the mapping. As string parts are commonly sent as subsets or
/// scoped names, this increases the likelyhood of a successful
/// string mapping.
/// </remarks>
/// <returns>
/// <c>true</c> if the string was added to the mapping for the first
/// time, <c>false</c> otherwise.
/// </returns>
/// <exception cref="InvalidOperationException">
/// Thrown if the string is not normalized (<see cref="String.IsNormalized()"/>).
/// </exception>
public void AddString(string str)
{
if (!_net.IsClient)
{
_dict.AddString(str);
}
}
/// <summary>
/// Add the constant strings from an <see cref="Assembly"/> to the
/// mapping.
/// </summary>
/// <param name="asm">The assembly from which to collect constant strings.</param>
public void AddStrings(Assembly asm)
{
if (!_net.IsClient)
{
_dict.AddStrings(asm);
}
}
/// <summary>
/// Add strings from the given <see cref="YamlStream"/> to the mapping.
/// </summary>
/// <remarks>
/// Strings are taken from YAML anchors, tags, and leaf nodes.
/// </remarks>
/// <param name="yaml">The YAML to collect strings from.</param>
public void AddStrings(YamlStream yaml)
{
if (!_net.IsClient)
{
_dict.AddStrings(yaml);
}
}
/// <summary>
/// Add strings from the given <see cref="JObject"/> to the mapping.
/// </summary>
/// <remarks>
/// Strings are taken from JSON property names and string nodes.
/// </remarks>
/// <param name="obj">The JSON to collect strings from.</param>
public void AddStrings(JObject obj)
{
if (!_net.IsClient)
{
_dict.AddStrings(obj);
}
}
/// <summary>
/// Add strings from the given enumeration to the mapping.
/// </summary>
/// <param name="strings">The strings to add.</param>
public void AddStrings(IEnumerable<string> strings)
{
if (!_net.IsClient)
{
_dict.AddStrings(strings);
}
}
/// <summary>
/// Implements <see cref="ITypeSerializer.Handles"/>.
/// Specifies that this implementation handles strings.
/// </summary>
bool ITypeSerializer.Handles(Type type) => type == typeof(string);
/// <summary>
/// Implements <see cref="ITypeSerializer.GetSubtypes"/>.
/// </summary>
IEnumerable<Type> ITypeSerializer.GetSubtypes(Type type) => Type.EmptyTypes;
/// <summary>
/// Implements <see cref="IStaticTypeSerializer.GetStaticWriter"/>.
/// </summary>
/// <seealso cref="WriteMappedString"/>
MethodInfo IStaticTypeSerializer.GetStaticWriter(Type type)
{
return WriteMappedStringMethodInfo;
}
/// <summary>
/// Implements <see cref="IStaticTypeSerializer.GetStaticReader"/>.
/// </summary>
/// <seealso cref="ReadMappedString"/>
MethodInfo IStaticTypeSerializer.GetStaticReader(Type type)
{
return ReadMappedStringMethodInfo;
}
/// <summary>
/// Write the encoding of the given string to the stream.
/// Static form of <see cref="WriteMappedString"/> for use by <see cref="NetSerializer"/>.
/// </summary>
/// <param name="stream">The stream to write to.</param>
/// <param name="value"> The (possibly null) string to write.</param>
private static void StaticWriteMappedString(Stream stream, string? value)
{
var mss = (RobustMappedStringSerializer) IoCManager.Resolve<IRobustMappedStringSerializer>();
mss._dict.WriteMappedString(stream, value);
}
/// <summary>
/// Try to read a string from the given stream.
/// Static form of <see cref="ReadMappedString"/> for use by <see cref="NetSerializer"/>.
/// </summary>
/// <param name="stream">The stream to read from.</param>
/// <param name="value"> The (possibly null) string read.</param>
/// <exception cref="InvalidOperationException">
/// Thrown if the mapping is not locked.
/// </exception>
private static void StaticReadMappedString(Stream stream, out string? value)
{
var mss = (RobustMappedStringSerializer) IoCManager.Resolve<IRobustMappedStringSerializer>();
mss._dict.ReadMappedString(stream, out value);
}
/// <summary>
/// See <see cref="OnClientCompleteHandshake"/>.
/// </summary>
public event Action? ClientHandshakeComplete;
public void LockStrings()
{
var sw = Stopwatch.StartNew();
_dict.FinalizeMapping();
LogSzr.Debug($"Finalized string mapping of size {_dict.StringCount} in {sw.ElapsedMilliseconds}ms");
sw.Restart();
(_stringMapHash, _mappedStringsPackage) = _dict.GeneratePackage();
LogSzr.Debug($"Wrote string package in {sw.ElapsedMilliseconds}ms size {ByteHelpers.FormatBytes(_mappedStringsPackage.Length)}");
LogSzr.Debug($"String hash is {ConvertToBase64Url(_stringMapHash)}");
}
public void Initialize()
{
LogSzr = Logger.GetSawmill("szr");
_dict = new MappedStringDict(LogSzr);
if (_net.IsClient)
{
// Client cannot make its own string dictionary, lock immediately.
_dict.Locked = true;
}
NetworkInitialize();
}
private sealed class InProgressHandshake
{
public readonly TaskCompletionSource<object?> Tcs;
public bool HasRequestedStrings;
public InProgressHandshake(TaskCompletionSource<object?> tcs)
{
Tcs = tcs;
}
}
}
}