using ExtensionMethods; using MAX.Models; using Microsoft.Extensions.Logging; using System; using System.Net.Sockets; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; using System.Xml; namespace MAX { public class Client : IDisposable { private ILogger _logger; private string _host; private int _port; private int _vendorId; private string _serialNumber; private int _userId; private string _username; private string _password; private bool _logResponses; private TcpClient _connection = null; private NetworkStream _connectionStream = null; private TripleDES _des = null; private bool _disposed = false; public Client(ILogger logger, bool logResponses, string host, int port, int vendorId, string serialNumber, int userId, string username, string password) { _logger = logger; _host = host; _port = port; _vendorId = vendorId; _serialNumber = serialNumber; _userId = userId; _username = username; _password = password; _logResponses = logResponses; ConnectTimeout = 10000; ReceiveTimeout = 10000; SendTimeout = 10000; } public Client(ILogger logger, bool logResponses, string host, int port, LoginCredentials credentials) : this(logger, logResponses, host, port, credentials.Vendor.Id, credentials.Vendor.SerialNumber, credentials.User.Id, credentials.User.Username, credentials.Password) { } public void Close() { Dispose(true); } public async Task ConnectAsync() { if (_connection != null) throw new Exception("Already connected"); _connection = new TcpClient(AddressFamily.InterNetwork); _connection.ReceiveTimeout = ReceiveTimeout; _connection.SendTimeout = SendTimeout; // Connect to the server try { using (var cancellationSource = new CancellationTokenSource(ConnectTimeout)) { await _connection.ConnectAsync(_host, _port).WithCancellation(cancellationSource.Token).ConfigureAwait(false); } } catch (OperationCanceledException) { throw new Exception("Connect timeout"); } _connectionStream = _connection.GetStream(); // Device authentication await WriteMessageAsync(new MessageBuilder() .Append("Hi ") .Append(_serialNumber) .Append("|V") .Append(_vendorId) .Append("|123451234512345||||||")).ConfigureAwait(false); var response = ExpectResponse(await ReadMessageAsync().ConfigureAwait(false), "Hi"); // Request server RSA key // // WARNING: // // The protocol does not do any form of server authentication so this step is // vulnerable to a man-in-the-middle attack where an intermediary intercepts this // request and sends their own RSA key while keeping the server RSA key to themselves. // This is not really an issue here as this is server to server communication and // therefore less likely to be intercepted. await WriteMessageAsync(new MessageBuilder().Append("PK")).ConfigureAwait(false); response = await ReadMessageAsync().ConfigureAwait(false); // Key exchange _des = TripleDES.Create(); _des.IV = new byte[8]; if (_logResponses) { _logger.LogDebug("Key for {0}: {1}", LoginCredentials.Format(_userId, _username, _vendorId, _serialNumber), BitConverter.ToString(_des.Key).Replace("-", "") ); } await WriteMessageAsync(new MessageBuilder() .Append("3D ") .Append(EncryptRSA(response, BitConverter.ToString(_des.Key).Replace("-", "")))).ConfigureAwait(false); response = ExpectResponse(await ReadMessageAsync().ConfigureAwait(false), "OK"); // User authentication await WriteMessageAsync(new MessageBuilder() .Append("User ") .Append(Encrypt(new StringBuilder() .Append(_userId) .Append("|") .Append(_username) .Append("|") .Append(_password).ToString()))).ConfigureAwait(false); response = ExpectResponse(Decrypt(await ReadMessageAsync().ConfigureAwait(false)), "OK"); var parts = response.Split('|'); var user = new User() { Id = _userId, Username = _username, FirstName = parts[4], Surname = parts[3], Enabled = ParseBool(parts[6], "User.Enabled(6)", response), Level = (User.UserLevel)ParseInt(parts[1], "User.Level(1)", response), System = ParseInt(parts[2], "User.System(2)", response), LastLogin = ParseDateTime(parts[5], "User.LastLogin(5)", response) }; if (user.Level == User.UserLevel.CustomUser) { user.CanPrintOffline = ParseBool(parts[7], "User.CanPrintOffline(7)", response); user.OfflinePrintValue = ParseDecimal(parts[8], "User.OfflinePrintValue(8)", response); user.CanPrintOnline = ParseBool(parts[9], "User.CanPrintOnline(9)", response); user.OnlinePrintValue = ParseDecimal(parts[10], "User.OnlinePrintValue(10)", response); user.CanReprintOffline = ParseBool(parts[11], "User.CanReprintOffline(11)", response); user.OfflineReprintValue = ParseDecimal(parts[12], "User.OfflineReprintValue(12)", response); user.CanReprintOnline = ParseBool(parts[13], "User.CanReprintOnline(13)", response); user.OnlineReprintValue = ParseDecimal(parts[14], "User.OnlineReprintValue(14)", response); user.BulkExport = ParseBool(parts[15], "User.BulkExport(15)", response); user.BulkExportMaxValue = ParseDecimal(parts[16], "User.BulkExportMaxValue(16)", response); user.BulkOrder = ParseBool(parts[17], "User.BulkOrder(17)", response); user.BulkOrderMaxValue = ParseDecimal(parts[18], "User.BulkOrderMaxValue(18)", response); user.BulkViewPins = ParseBool(parts[19], "User.BulkViewPins(19)", response); user.BulkReExport = ParseBool(parts[20], "User.BulkReExport(20)", response); } return user; } public int ConnectTimeout { get; set; } protected virtual void Dispose(bool disposing) { if (_disposed) return; _disposed = true; // No unmanaged resources are disposed so we don't need the full finalisation pattern. if (disposing) { if (_des != null) { _des.Dispose(); _des = null; } if (_connectionStream != null) { _connectionStream.Dispose(); _connectionStream = null; } if (_connection != null) { _connection.Dispose(); _connection = null; } } } public void Dispose() { Dispose(true); } private string Decrypt(string cipherText) { var response = Utils.TripleDESDecrypt(cipherText, _des); if (_logResponses) { _logger.LogDebug("Decrypted response for {0}: {1}", LoginCredentials.Format(_userId, _username, _vendorId, _serialNumber), response); } return response; } private string Encrypt(string plainText) { return Utils.TripleDESEncrypt(plainText, _des); } private string EncryptRSA(string publicKey, string plainText) { RSAParameters parameters = new RSAParameters(); var xml = new XmlDocument(); xml.LoadXml(publicKey); if (! xml.DocumentElement.Name.Equals("RSAKeyValue")) throw new Exception("Invalid RSA key"); foreach (XmlNode node in xml.DocumentElement.ChildNodes) { switch (node.Name) { case "Modulus": parameters.Modulus = Convert.FromBase64String(node.InnerText); break; case "Exponent": parameters.Exponent = Convert.FromBase64String(node.InnerText); break; case "P": parameters.P = Convert.FromBase64String(node.InnerText); break; case "Q": parameters.Q = Convert.FromBase64String(node.InnerText); break; case "DP": parameters.DP = Convert.FromBase64String(node.InnerText); break; case "DQ": parameters.DQ = Convert.FromBase64String(node.InnerText); break; case "InverseQ": parameters.InverseQ = Convert.FromBase64String(node.InnerText); break; case "D": parameters.D = Convert.FromBase64String(node.InnerText); break; } } using (var rsa = RSA.Create()) { rsa.ImportParameters(parameters); var blockSize = rsa.KeySize / 8 - 42; var offset = 0; var input = Encoding.UTF32.GetBytes(plainText); StringBuilder output = new StringBuilder(); while (offset < input.Length) { var length = input.Length - offset; if (length > blockSize) length = blockSize; var block = new byte[length]; Array.Copy(input, offset, block, 0, length); var cipherText = rsa.Encrypt(block, RSAEncryptionPadding.OaepSHA1); Array.Reverse(cipherText); output.Append(Convert.ToBase64String(cipherText)); offset += length; } return output.ToString(); } } private string ExpectResponse(string response, string prefix) { if (response.StartsWith("ER")) { var parts = response.Split('|'); int errorCode; if ((parts.Length < 2) || !int.TryParse(parts[1], out errorCode)) { errorCode = -1; } var message = parts.Length >= 3 ? parts[2] : String.Format("Malformed server error: {0}", response); _logger.LogError("MAX Error for {0}: {1} (code {2})", LoginCredentials.Format(_userId, _username, _vendorId, _serialNumber), message, errorCode); throw new MAXException(errorCode, message); } else if (!response.StartsWith(prefix)) { _logger.LogError("Invalid MAX response for {0}: {1}", LoginCredentials.Format(_userId, _username, _vendorId, _serialNumber), response); throw new Exception(String.Format("Invalid server response: {0}", response)); } return response; } public async Task GetAccountAsync() { await WriteMessageAsync(new MessageBuilder().Append("Acc")).ConfigureAwait(false); var response = ExpectResponse(Decrypt(await ReadMessageAsync().ConfigureAwait(false)), "OK"); var parts = response.Split('|'); return new Account() { Id = ParseInt(parts[1], "Account.Id(1)", response), Name = parts[2], Balance = ParseDecimal(parts[3], "Account.Balance(3)", response), Status = (Account.AccountStatus)ParseInt(parts[4], "Account.AccountStatus(4)", response), Reference = parts[5], Warehouse = new Warehouse() { Id = ParseInt(parts[6], "Account.Warehouse.Id(6)", response), Name = parts[7] } }; } public async Task GetProductCatalogueAsync(Account account) { var encryptedWarehouseName = Encrypt(account.Warehouse.Name); await WriteMessageAsync(new MessageBuilder() .Append("Pdt ") .Append(encryptedWarehouseName)).ConfigureAwait(false); var response = ExpectResponse(Decrypt(await ReadMessageAsync().ConfigureAwait(false)), "OK"); var parts = response.Split('|'); var count = ParseInt(parts[1], "Products.Count(1)", response); var catalogue = new ProductCatalogue(); var listCommand = new MessageBuilder().Append("List ") .Append(encryptedWarehouseName).GetBytes(); for (var i = 0; i < count; i++) { await _connectionStream.WriteAsync(listCommand, 0, listCommand.Length).ConfigureAwait(false); response = ExpectResponse(Decrypt(await ReadMessageAsync().ConfigureAwait(false)), "OK"); parts = response.Split('|'); int networkId = ParseInt(parts[4], "Product.NetworkId(4)", response); Network network; if (! catalogue.NetworkMap.TryGetValue(networkId, out network)) { network = catalogue.AddNetwork(networkId, parts[5]); } catalogue.AddProduct( network: network, id: ParseInt(parts[1], "Product.Id(1)", response), faceValue: ParseDecimal(parts[2], "Product.FaceValue(2)", response), description: parts[3], voucherType: (Batch.Vouchertype)ParseInt(parts[6], "Product.VoucherType(6)", response), discountPercentage: ParseDecimal(parts[7], "Product.DiscountPercentage(7)", response) ); } return catalogue; } private bool ParseBool(string value, string valueName, string fullResponse) { bool ret; if (! bool.TryParse(value, out ret)) { ThrowParseError(value, valueName, "bool", fullResponse); } return ret; } private DateTime ParseDateTime(string value, string valueName, string fullResponse) { DateTime ret; if (!DateTime.TryParse(value, out ret)) { ThrowParseError(value, valueName, "DateTime", fullResponse); } return ret; } private decimal ParseDecimal(string value, string valueName, string fullResponse) { decimal ret; if (!decimal.TryParse(value, out ret)) { double fallback; if (!double.TryParse(value, out fallback)) { ThrowParseError(value, valueName, "decimal", fullResponse); } return (decimal)fallback; } return ret; } private int ParseInt(string value, string valueName, string fullResponse) { int ret; if (!int.TryParse(value, out ret)) { ThrowParseError(value, valueName, "int", fullResponse); } return ret; } public async Task PlaceOrderAsync(int accountId, Product product, int quantity, string customerReference, string internalReference, Guid? orderGuid, byte[] key) { if (key.Length != 24) { throw new ArgumentException("24 byte key expected", nameof(key)); } _logger.LogDebug( "Placing order for {0}: date={1} quantity={2} productId={3} productDescription={4} networkId={5} networkName={6} customerRef={7} internalRef={8}", LoginCredentials.Format(_userId, _username, _vendorId, _serialNumber), DateTimeOffset.UtcNow, quantity, product.Id, product.Description, product.Network.Id, product.Network.Name, customerReference, internalReference ); await WriteMessageAsync(new MessageBuilder() .Append("Order ") .Append(Encrypt(new StringBuilder() .Append(product.Id) .Append("|") .Append(quantity) .Append("|") .Append(customerReference) .Append("|2|") // EncType: 0:None, 1:DES, 2:Triple DES .Append(BitConverter.ToString(key, 0, 8).Replace("-", "")) .Append("|") .Append(BitConverter.ToString(key, 8, 8).Replace("-", "")) .Append("|") .Append(BitConverter.ToString(key, 16, 8).Replace("-", "")) .Append("|") .Append(internalReference) .ToString()))).ConfigureAwait(false); var response = ExpectResponse(Decrypt(await ReadMessageAsync().ConfigureAwait(false)), "OK"); _logger.LogDebug("Order response for {0} customerRef={1} internalRef={2}: {3}", LoginCredentials.Format(_userId, _username, _vendorId, _serialNumber), customerReference, internalReference, response ); var parts = response.Split('|'); var deliveredQuantity = ParseInt(parts[4], "Batch.DeliveredQuantity(4)", response); return new OrderResponse() { Batch = new Batch() { Id = ParseInt(parts[1], "Batch.Id(1)", response), OrderReference = parts[2], RequestedQuantity = ParseInt(parts[3], "Batch.RequestQuantity(3)", response), DeliveredQuantity = deliveredQuantity, Cost = ParseDecimal(parts[5], "Batch.Cost(5)", response), InternalReference = internalReference, OrderGuid = orderGuid, AccountId = accountId, VendorId = _vendorId, ProductId = product.Id, ProductDescription = product.Description, VoucherType = product.VoucherType, FaceValue = product.FaceValue, DiscountPercentage = product.DiscountPercentage, NetworkId = product.Network.Id, NetworkName = product.Network.Name, OrderDate = DateTimeOffset.UtcNow, OrderedById = _userId, ReadyForDownload = deliveredQuantity == 0 }, RemainingBalance = ParseDecimal(parts[6], "Batch.RemainingBalance(6)", response) }; } private async Task ReadBytesAsync(int count) { int totalBytesRead = 0; byte[] buffer = new byte[count]; while (totalBytesRead < count) { int bytesRead = await _connectionStream.ReadAsync(buffer, totalBytesRead, count - totalBytesRead).ConfigureAwait(false); if (bytesRead == 0) throw new Exception("Connection closed unexpectedly"); totalBytesRead += bytesRead; } return buffer; } private async Task ReadMessageAsync() { byte[] buffer = await ReadBytesAsync(2).ConfigureAwait(false); int size = buffer[0] * 256 + buffer[1]; if (size <= 0) { throw new Exception("Invalid message size"); } var response = Encoding.ASCII.GetString(await ReadBytesAsync(size).ConfigureAwait(false)); if (_logResponses) { _logger.LogDebug("Response for {0}: {1}", LoginCredentials.Format(_userId, _username, _vendorId, _serialNumber), response); } return response; } public int ReceiveTimeout { get; set; } public async Task ReExportBatchAsync(int batchId, byte[] key) { if (key.Length != 24) { throw new ArgumentException("24 byte key expected", nameof(key)); } await WriteMessageAsync(new MessageBuilder() .Append("ReExport ") .Append(Encrypt(new StringBuilder() .Append(batchId) .Append("|2|") // EncType: 0:None, 1:DES, 2:Triple DES .Append(BitConverter.ToString(key, 0, 8).Replace("-", "")) .Append("|") .Append(BitConverter.ToString(key, 8, 8).Replace("-", "")) .Append("|") .Append(BitConverter.ToString(key, 16, 8).Replace("-", "")) .ToString()))).ConfigureAwait(false); ExpectResponse(Decrypt(await ReadMessageAsync().ConfigureAwait(false)), "OK"); } public int SendTimeout { get; set; } private void ThrowParseError(string value, string valueName, string valueType, string fullResponse) { _logger.LogError( "Failed to parse value: valueType={0} valueName={1} value={2} fullResponse={3} {4}", valueType, valueName, value, fullResponse, LoginCredentials.Format(_userId, _username, _vendorId, _serialNumber) ); throw new Exception(String.Format("Invalid value for {0}", valueName)); } private async Task WriteMessageAsync(MessageBuilder message) { byte[] data = message.GetBytes(); if (_logResponses) { _logger.LogDebug("Request for {0}: {1}", LoginCredentials.Format(_userId, _username, _vendorId, _serialNumber), Encoding.ASCII.GetString(data, 2, data.Length - 2) ); } await _connectionStream.WriteAsync(data, 0, data.Length).ConfigureAwait(false); } } }