Last active
July 13, 2018 09:33
-
-
Save wilson0x4d/a659723373ab2dd5ac845ba8a92ebb84 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using Newtonsoft.Json; | |
using System; | |
using System.Collections.Generic; | |
using System.Diagnostics; | |
using System.IO; | |
using System.Linq; | |
using System.Net; | |
using System.Net.Sockets; | |
using System.Security.Cryptography; | |
using System.Text; | |
using System.Threading; | |
using System.Threading.Tasks; | |
namespace shenc | |
{ | |
/// <summary> | |
/// A quick and dirty tool for encrypting/decrypting text from a shell prompt. | |
/// <para>Generates a one-time keypair, used for encryption/decryption.</para> | |
/// <para>PUBLIC key stored to `.pubkey` file.</para> | |
/// <para>PRIVATE key stored to `.prikey` file.</para> | |
/// <para>Offers a "chat" mode with whitelist authorization by thumbprint.</para> | |
/// </summary> | |
internal class Program | |
{ | |
private static IDictionary<string, ClientState> _clients; | |
private static IDictionary<string/*thumbprint*/, string/*display alias*/> _whitelist; | |
private static TcpListener _listener; | |
private static int _processId; | |
private static void Main(string[] args) | |
{ | |
_processId = Process.GetCurrentProcess().Id; | |
try | |
{ | |
if (args == null || args.Length == 0) | |
{ | |
PrintHelp(); | |
return; | |
} | |
UpdateDynamicDns(); | |
var opcode = args[0].ToUpperInvariant(); | |
var keyid = args.Length > 1 | |
? args[1] | |
: default(string); | |
var input = args.Length > 2 | |
? string.Join(" ", args.Skip(2)) | |
: default(string); | |
switch (opcode) | |
{ | |
case "CHAT": | |
{ | |
if (string.IsNullOrWhiteSpace(keyid)) | |
{ | |
keyid = "chat"; | |
} | |
var rsa = LoadKeypair(keyid, true); // ie. "My" key, the key used to decrypt incoming data | |
var cancellationTokenSource = new CancellationTokenSource(); | |
SwitchToInteractiveMode(rsa, cancellationTokenSource); | |
} | |
return; | |
case "GENKEYS": | |
case "G": | |
GenerateKeypair(keyid, int.Parse(input ?? "8192")); | |
return; | |
case "ENCRYPT": | |
case "E": | |
Encrypt(keyid, input); | |
break; | |
case "DECRYPT": | |
case "D": | |
Decrypt(keyid, input); | |
break; | |
case "HASH": | |
case "H": | |
Hash(keyid); | |
break; | |
default: | |
PrintHelp(); | |
return; | |
} | |
} | |
catch (Exception ex) | |
{ | |
Log(ex); | |
} | |
} | |
private static void Hash(string keyid) | |
{ | |
using (var rsa = LoadKeypair(keyid, false)) | |
{ | |
var thumbprint = GetThumbprint(rsa); | |
Log($"HASH: {keyid}=\"{thumbprint}\""); | |
} | |
} | |
private static string WhatsMyIP() | |
{ | |
var webClient = new WebClient(); | |
webClient.Headers.Add("User-Agent", "shenc/0.1 [email protected]"); | |
var response = webClient.DownloadString("https://ipapi.co/json/"); | |
dynamic obj = JsonConvert.DeserializeObject(response); | |
return (obj != null && !string.IsNullOrWhiteSpace(Convert.ToString(obj.ip))) | |
? obj.ip | |
: Dns.GetHostEntry(IPAddress.Any).AddressList.FirstOrDefault(); | |
} | |
private static bool TryWebGet( | |
Uri uri, | |
string userName, | |
string password, | |
out string result) | |
{ | |
try | |
{ | |
var webClient = new WebClient | |
{ | |
Credentials = new NetworkCredential(userName, password), | |
CachePolicy = new System.Net.Cache.RequestCachePolicy(System.Net.Cache.RequestCacheLevel.NoCacheNoStore), | |
Encoding = Encoding.UTF8 | |
}; | |
webClient.Headers.Add("User-Agent", "shenc/0.1 [email protected]"); | |
result = webClient.DownloadString(uri); | |
return true; | |
} | |
catch (Exception ex) | |
{ | |
// TODO: use a logging framework, instead | |
result = (new StringBuilder($"Exception: {ex.GetType().FullName}")) | |
.AppendLine($"Exception: {ex.GetType().FullName}") | |
.AppendLine($"Message: {ex.Message}") | |
.AppendLine($"StackTrace: {ex.StackTrace}") | |
.ToString(); | |
return false; | |
} | |
} | |
private static void UpdateDynamicDns() | |
{ | |
try | |
{ | |
var hostname = System.Configuration.ConfigurationManager.AppSettings["no-ip:hostname"]; | |
var auth = System.Configuration.ConfigurationManager.AppSettings["no-ip:auth"]; | |
var keyid = System.Configuration.ConfigurationManager.AppSettings["no-ip:key"] ?? "chat"; | |
if (!string.IsNullOrEmpty(hostname) && !string.IsNullOrEmpty(auth)) | |
{ | |
var key = LoadKeypair(keyid, false); | |
var edata = Convert.FromBase64String(auth); | |
var data = key.Decrypt(edata, RSAEncryptionPadding.Pkcs1); | |
var parts = Encoding.UTF8.GetString(data).Split(':'); | |
var userName = parts[0]; | |
var password = parts[1]; | |
var ipaddr = System.Configuration.ConfigurationManager.AppSettings["no-ip:address"] ?? WhatsMyIP(); | |
var ddnsSuccess = TryWebGet( | |
new Uri($"https://dynupdate.no-ip.com/nic/update?hostname={hostname}&myip={ipaddr}"), | |
userName, | |
password, | |
out string result); | |
Log($"=== DDNS RESULT: {(ddnsSuccess ? "SUCCESS" : "FAILED")}> {result}"); | |
} | |
} | |
catch (Exception ex) | |
{ | |
Log(ex); | |
} | |
} | |
private static void SwitchToInteractiveMode( | |
RSA rsa, | |
CancellationTokenSource cancellationTokenSource) | |
{ | |
_whitelist = LoadWhitelist() | |
.ToDictionary( | |
kvp => kvp.Key, | |
kvp => kvp.Value); | |
_clients = new Dictionary<string, ClientState>(StringComparer.OrdinalIgnoreCase); | |
while (!cancellationTokenSource.IsCancellationRequested) | |
{ | |
var command = Console.ReadLine(); | |
if (string.IsNullOrWhiteSpace(command)) | |
{ | |
continue; | |
} | |
var commandParts = command.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); | |
if (commandParts.Length > 0) | |
{ | |
commandParts[0] = commandParts[0].ToUpperInvariant(); | |
switch (commandParts[0]) | |
{ | |
case "/HELP": | |
case "/?": | |
PrintInteractiveHelp(commandParts.Length > 1 ? commandParts[1] : commandParts[0]); | |
break; | |
case "/QUIT": | |
try | |
{ | |
// shutdown all client workers | |
lock (_clients) | |
{ | |
Task.WaitAll(_clients.Values.Select(StopClientWorker).ToArray()); | |
} | |
} | |
finally | |
{ | |
// shutdown self | |
cancellationTokenSource.Cancel(false); | |
} | |
break; | |
case "/LISTEN": | |
// TODO: control accept queue length | |
#pragma warning disable 4014 | |
StartListening( | |
cancellationTokenSource, | |
commandParts.Length > 1 ? int.Parse(commandParts[1]) : 18593, | |
(client) => OnClientAcceptCallback(client, rsa)); | |
#pragma warning restore 4014 | |
break; | |
case "/DISCONNECT": | |
{ | |
lock (_clients) | |
{ | |
var clients = _clients.Values | |
.Where(client => commandParts.Any(e => | |
e == "*" // accept a wildcard for "all" clients | |
|| e.Equals(client.Alias, StringComparison.InvariantCultureIgnoreCase) | |
|| e.Equals($"{client.HostName}:{client.PortNumber}", StringComparison.OrdinalIgnoreCase) | |
|| e.Equals(client.Thumbprint, StringComparison.OrdinalIgnoreCase))); | |
foreach (var client in clients) | |
{ | |
#pragma warning disable 4014 | |
StopClientWorker(client); | |
#pragma warning restore 4014 | |
} | |
} | |
} | |
break; | |
case "/CONNECT": | |
// treat each command input as a 'hostport' | |
commandParts.Skip(1).Select(async hostport => | |
{ | |
try | |
{ | |
var client = await ConnectTo(hostport, rsa); | |
} | |
catch (Exception ex) | |
{ | |
Log(ex); | |
} | |
}) | |
.ToArray(); | |
break; | |
case "/NOLISTEN": | |
case "/NOHOST": | |
StopListening(); | |
break; | |
case "/PING": | |
{ | |
// manual ping initiation for all hosts | |
var clients = default(IEnumerable<Task>); | |
lock (_clients) | |
{ | |
clients = _clients.Values | |
.Select(PING) | |
.ToArray(); // fire and forget. | |
} | |
} | |
break; | |
case "/ACCEPT": | |
{ | |
if (commandParts.Length < 2) | |
{ | |
PrintInteractiveHelp(commandParts[0]); | |
} | |
else | |
{ | |
lock (_whitelist) | |
{ | |
var thumbprint = commandParts[1]; | |
_whitelist.TryGetValue(thumbprint, out string L_alias); | |
var alias = commandParts.Length > 2 | |
? commandParts[2] | |
: L_alias | |
?? thumbprint; | |
_whitelist[thumbprint] = alias; | |
lock (_clients) | |
{ | |
foreach (var client in _clients.Values) | |
{ | |
if (client.Thumbprint.Equals(thumbprint, StringComparison.OrdinalIgnoreCase)) | |
{ | |
client.Alias = alias; | |
} | |
} | |
} | |
StoreWhitelist(); | |
Log($"ACCEPT: '{thumbprint}' => '{alias}'"); | |
} | |
} | |
} | |
break; | |
case "/BAN": | |
{ | |
// remove from whitelist, each command part would be a new thumbprint | |
lock (_clients) | |
lock (_whitelist) | |
{ | |
var clients = _clients.Values | |
.Where(client => commandParts.Any(e => | |
e.Equals(client.Alias, StringComparison.InvariantCultureIgnoreCase) | |
|| e.Equals($"{client.HostName}:{client.PortNumber}", StringComparison.OrdinalIgnoreCase) | |
|| e.Equals(client.Thumbprint, StringComparison.OrdinalIgnoreCase))) | |
.ToArray(); | |
var blacklist = _whitelist | |
.Where(kvp => commandParts.Any(e => | |
kvp.Key.Equals(e, StringComparison.OrdinalIgnoreCase)) | |
|| commandParts.Any(e => kvp.Value.Equals(e, StringComparison.OrdinalIgnoreCase))) | |
.Select(kvp => kvp.Key) | |
.ToArray(); | |
foreach (var thumbprint in blacklist) | |
{ | |
_whitelist.Remove(thumbprint); | |
Log($"BAN: {thumbprint}"); | |
} | |
StoreWhitelist(); | |
foreach (var client in clients) | |
{ | |
#pragma warning disable 4014 | |
StopClientWorker(client); | |
#pragma warning restore 4014 | |
} | |
} | |
} | |
break; | |
case "/WHITELIST": | |
{ | |
lock (_whitelist) | |
{ | |
foreach (var thumbprint in _whitelist) | |
{ | |
Log($"WHITELIST: {thumbprint}"); | |
} | |
} | |
} | |
break; | |
default: | |
{ | |
// write message to all connected clients (like a chat room) | |
var tasks = default(IEnumerable<Task>); | |
var failures = new List<ClientState>(); | |
lock (_clients) | |
{ | |
tasks = _clients.Values | |
.Select(client => Task.Factory.StartNew(async () => | |
{ | |
try | |
{ | |
Send(client, command); | |
} | |
catch (Exception ex) | |
{ | |
Log(ex); | |
failures.Add(client); | |
} | |
await Task.CompletedTask; | |
})) | |
.ToArray(); | |
} | |
if (tasks.Any()) | |
{ | |
Task.WaitAll(tasks.ToArray()); | |
tasks = failures.Select(client => | |
{ | |
try | |
{ | |
lock (_clients) | |
{ | |
if (_clients.Remove($"{client.HostName}:{client.PortNumber}")) | |
{ | |
DebugLog($"FAILED: Removed {client}"); | |
return StopClientWorker(client); | |
} | |
} | |
} | |
catch (Exception ex) | |
{ | |
Log(ex); | |
} | |
return Task.CompletedTask; | |
}).ToArray(); | |
if (tasks.Any()) | |
{ | |
Task.WaitAll(tasks.ToArray()); | |
} | |
} | |
} | |
break; | |
} | |
} | |
} | |
} | |
private static void OnClientAcceptCallback(ClientState client, RSA rsa) | |
{ | |
var hostport = $"{client.HostName}:{client.PortNumber}"; | |
DebugLog($"{nameof(OnClientAcceptCallback)}({client},{GetThumbprint(rsa)}) via [{hostport}]"); | |
lock (_clients) | |
{ | |
if (_clients.TryGetValue(hostport, out ClientState existingClient)) | |
{ | |
if (client.Thumbprint == existingClient.Thumbprint || string.IsNullOrEmpty(existingClient.Thumbprint)) | |
{ | |
_clients[hostport] = client; | |
Log($"LISTEN: Replacing '{existingClient}' with '{client}'.."); | |
#pragma warning disable 4014 | |
StopClientWorker(existingClient); | |
#pragma warning restore 4014 | |
} | |
else | |
{ | |
Log($"LISTEN: Denying reconnction attempt for {hostport} because thumbprint '{client.Thumbprint}' does not match prior thumbprint '{existingClient.Thumbprint}'."); | |
return; | |
} | |
} | |
else | |
{ | |
_clients[hostport] = client; | |
Log($"LISTEN: Added '{client}'"); | |
} | |
} | |
client.Worker = StartClientWorker(client, rsa); | |
client.Worker.ContinueWith((t) => | |
{ | |
lock (_clients) | |
{ | |
if (_clients.Remove(hostport)) | |
{ | |
DebugLog($"LISTEN: Removed {client}"); | |
} | |
} | |
}); | |
} | |
private static void StoreWhitelist() | |
{ | |
lock (_whitelist) | |
{ | |
if (_whitelist.Count > 0) | |
{ | |
using (var writer = new StreamWriter( | |
File.Open("whitelist.txt", FileMode.Create, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete))) | |
{ | |
_whitelist.ToList().ForEach(kvp => writer.WriteLine($"{kvp.Key},{kvp.Value}")); | |
writer.Flush(); | |
writer.Close(); | |
} | |
} | |
} | |
} | |
private static IEnumerable<KeyValuePair<string, string>> LoadWhitelist() | |
{ | |
if (File.Exists("whitelist.txt")) | |
{ | |
using (var reader = new StreamReader( | |
File.Open("whitelist.txt", FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))) | |
{ | |
var line = reader.ReadLine(); | |
while (line != null) | |
{ | |
var parts = line.Split(','); | |
var thumbprint = parts[0]; | |
var alias = parts.Length > 1 ? parts[1] : thumbprint; | |
yield return new KeyValuePair<string, string>(thumbprint, alias); | |
line = reader.ReadLine(); | |
} | |
} | |
} | |
} | |
private static async Task<ClientState> ConnectTo(string hostport, RSA rsa) | |
{ | |
var parts = hostport.Split(new[] { ':' }, StringSplitOptions.RemoveEmptyEntries); | |
var hostName = parts[0]; | |
var portNumber = parts.Length > 1 ? int.Parse(parts[1]) : 18593; | |
hostport = $"{hostName}:{portNumber}"; | |
var tcpClient = new TcpClient(); | |
tcpClient.NoDelay = true; | |
Log($"Requesting connection to [{hostport}].."); | |
await tcpClient.ConnectAsync(hostName, portNumber); | |
var client = default(ClientState); | |
lock (_clients) | |
{ | |
client = new ClientState | |
{ | |
HostName = hostName, | |
PortNumber = portNumber, | |
TcpClient = tcpClient | |
}; | |
if (_clients.TryGetValue(hostport, out ClientState existingClient)) | |
{ | |
_clients[hostport] = client; | |
Log($"CONNECT: Replacing '{existingClient}' with '{client}'.."); | |
#pragma warning disable 4014 | |
StopClientWorker(existingClient); | |
#pragma warning restore 4014 | |
} | |
else | |
{ | |
_clients[hostport] = client; | |
Log($"CONNECT: Added '{client}'"); | |
} | |
} | |
#pragma warning disable 4014 | |
client.Worker = StartClientWorker(client, rsa); | |
client.Worker.ContinueWith((t) => | |
{ | |
lock (_clients) | |
{ | |
if (_clients.Remove(hostport)) | |
{ | |
DebugLog($"LISTEN: Removed {client}"); | |
} | |
} | |
}); | |
#pragma warning restore 4014 | |
return client; | |
} | |
private static void Send(ClientState client, string message) | |
{ | |
DebugLog($"{nameof(Send)}({client},{message})"); | |
var data = Encoding.UTF8.GetBytes(message); | |
var edata = client.RSA == null ? data : client.RSA.Encrypt(data, RSAEncryptionPadding.Pkcs1); | |
client.TcpClient.Client.Send(BitConverter.GetBytes(edata.Length), SocketFlags.Partial); | |
client.TcpClient.Client.Send(edata, SocketFlags.Partial); | |
} | |
private static async Task StartClientWorker( | |
ClientState client, | |
RSA rsa) | |
{ | |
client.CancellationTokenSource = new CancellationTokenSource(); | |
// exchange our pubkey in the clear, this starts our conversation with remote | |
var rsaParameters = rsa.ExportParameters(false); | |
var pubkey = JsonConvert.SerializeObject(rsaParameters); | |
Send(client, pubkey); | |
var buf = new byte[1024 * 1024 * 48]; | |
var expectedSize = 0; | |
var writeOffset = 0; | |
var readOffset = 0; | |
try | |
{ | |
using (var stream = client.TcpClient.GetStream()) | |
{ | |
var isSTUN = false; | |
while (!client.CancellationTokenSource.IsCancellationRequested) | |
{ | |
if (expectedSize == 0) | |
{ | |
var availableCount = (writeOffset - readOffset); | |
if (availableCount >= 4) | |
{ | |
expectedSize = BitConverter.ToInt32(buf, readOffset); | |
if (expectedSize == 0) | |
{ | |
isSTUN = true; | |
expectedSize = 4; | |
} | |
else | |
{ | |
isSTUN = false; | |
} | |
readOffset += 4; | |
if (expectedSize > (buf.Length - readOffset)) | |
{ | |
throw new IndexOutOfRangeException($"Size Prefix '{expectedSize}' exceeds '{buf.Length}' for [{client.HostName}:{client.PortNumber}]"); | |
} | |
} | |
} | |
else if ((writeOffset - readOffset) >= expectedSize) | |
{ | |
var edata = new byte[expectedSize]; | |
expectedSize = 0; | |
Array.Copy(buf, readOffset, edata, 0, edata.Length); | |
readOffset += edata.Length; | |
if (client.RSA == null) | |
{ | |
// expect to receive pubkey from remote in the clear | |
var clientRSA = RSA.Create(); | |
clientRSA.ImportParameters( | |
JsonConvert.DeserializeObject<RSAParameters>( | |
Encoding.UTF8.GetString(edata))); | |
client.RSA = clientRSA; // late assignment avoids race condition (invalid thumbprint result) from interstitial before import completes. | |
// check client pubkey thumbprint against whitelist, if not in whitelist then force a disconnect | |
lock (_whitelist) | |
{ | |
if (_whitelist.TryGetValue(client.Thumbprint, out string alias)) | |
{ | |
Log($"Connected to {client}"); | |
} | |
else | |
{ | |
Log($"Rejecting {client}, thumbprint is not authorized."); | |
Log($"You can use the `/ACCEPT <thumbprint>` and `/BAN <thumbprint>` commands to authorized/deauthorize."); | |
break; | |
} | |
} | |
} | |
else | |
{ | |
var data = rsa.Decrypt(edata, RSAEncryptionPadding.Pkcs1); | |
if (isSTUN) | |
{ | |
// TODO: stun | |
} | |
else | |
{ | |
var message = Encoding.UTF8.GetString(data); | |
Log($"({DateTime.UtcNow.ToString("HH:mm:ss")}) {client}> {message}"); | |
} | |
} | |
} | |
var count = (expectedSize > 0) | |
? expectedSize | |
: 4; | |
var cb = 0; | |
try | |
{ | |
// TODO: implement PING, and then set a timeout for this read op that is (ping_interval*1.5) (ie. if no read within ping interval + grace period assume a dead link.) | |
var readTimeoutToken = new CancellationTokenSource(TimeSpan.FromMinutes(2)).Token; | |
cb = await stream.ReadAsync(buf, writeOffset, count, readTimeoutToken); | |
} | |
catch (Exception ex) | |
{ | |
// NOP: the reasons for a failed read are all valid, and should result in a disconnect sequence | |
break; | |
} | |
writeOffset += cb; | |
if (cb == 0) | |
{ | |
Log($"WORKER: Disconnection request detected for [{client.HostName}:{client.PortNumber}]"); | |
// remote closure initiated | |
client.CancellationTokenSource.Cancel(); | |
break; | |
} | |
else if (writeOffset >= buf.Length) | |
{ | |
// TODO: gracefully d/c the offending client instead | |
throw new RankException($"Internal buffer overflow detected for [{client.HostName}:{client.PortNumber}]"); | |
} | |
else if (readOffset > writeOffset) | |
{ | |
throw new RankException($"Internal buffer underflow detected for [{client.HostName}:{client.PortNumber}]"); | |
} | |
else if (readOffset == writeOffset && expectedSize == 0) | |
{ | |
// the logic basically states if we're not expecting data, reset buffer state to avoid overflow | |
readOffset = 0; | |
writeOffset = 0; | |
} | |
} | |
stream.Close(10 * 1000); | |
} | |
} | |
catch (Exception ex) | |
{ | |
Log(ex); | |
} | |
finally | |
{ | |
client.TcpClient.Close(); | |
client.TcpClient.Dispose(); | |
client.TcpClient = null; | |
Log($"WORKER: Disconnected from client"); | |
} | |
} | |
private static string GetThumbprint(RSA rsa) | |
{ | |
var result = default(string); | |
using (var sha1 = SHA1.Create()) | |
{ | |
var rsaParameters = rsa.ExportParameters(false); | |
var json = JsonConvert.SerializeObject(rsaParameters); | |
var data = Encoding.UTF8.GetBytes(json); | |
var hashed = sha1.ComputeHash(data); | |
result = string.Join(":", hashed.Select(e => e.ToString("X2")).ToArray()).ToLower(); | |
} | |
return result; | |
} | |
private static string GetAlias(string thumbprint) | |
{ | |
lock (_whitelist) | |
{ | |
foreach (var kvp in _whitelist) | |
{ | |
if (kvp.Key.Equals(thumbprint, StringComparison.OrdinalIgnoreCase)) | |
{ | |
return kvp.Value; | |
} | |
} | |
} | |
return thumbprint; | |
} | |
private static async Task PING(ClientState client) | |
{ | |
// TODO: ping should execute at regular intervals for keepalive and/or orphan/stale/dead link detection | |
// TODO: ping should perform a "CHAL", ie. the PONG response should contain our signature, signed by the remote, which we can then use to authenticate the link. in a "man in the middle" attack should yield either a bad PING sig or a bad PONG sig which should result in a hard disconnect from either end | |
await Task.CompletedTask; // in most cases, ping() is fire-and-forget. except during initial connection, we use ping/pong as an initial CHAL | |
} | |
private static async Task StartListening( | |
CancellationTokenSource cancellationTokenSource, | |
int port, | |
Action<ClientState> onAcceptCallback) | |
{ | |
if (_listener == null) | |
{ | |
_listener = new TcpListener(IPAddress.Any, port); | |
_listener.Start(10); | |
Log($"LISTEN: Listening for connections on port '{port}'.."); | |
while (!cancellationTokenSource.IsCancellationRequested) | |
{ | |
var client = await _listener.AcceptTcpClientAsync(); | |
client.NoDelay = true; | |
var remoteEndPoint = (client.Client.RemoteEndPoint as IPEndPoint); | |
DebugLog($"Connection request from [{remoteEndPoint.Address}:{remoteEndPoint.Port}]"); | |
onAcceptCallback(new ClientState | |
{ | |
HostName = $"{remoteEndPoint.Address}", | |
PortNumber = remoteEndPoint.Port, | |
TcpClient = client | |
}); | |
} | |
_listener.Stop(); | |
} | |
} | |
private static void StopListening() | |
{ | |
DebugLog($"{nameof(StopListening)}()"); | |
var listener = _listener; | |
_listener = null; | |
if (listener != null) | |
{ | |
try | |
{ | |
listener.Stop(); | |
} | |
catch (Exception ex) | |
{ | |
Log(ex); | |
} | |
} | |
Log("NOLISTEN: Stopped listening."); | |
} | |
private static async Task StopClientWorker(ClientState client) | |
{ | |
DebugLog($"{nameof(StopClientWorker)}({client})"); | |
try | |
{ | |
var clientWorker = client.Worker; | |
client.CancellationTokenSource.Cancel(); | |
if (clientWorker != null) | |
{ | |
await clientWorker; | |
} | |
} | |
catch (Exception ex) | |
{ | |
Log(ex); | |
} | |
} | |
#region Interactive Help | |
private static void PrintInteractiveHelp(string command) | |
{ | |
switch (command.Trim('/')) | |
{ | |
case "LISTEN": | |
Console.WriteLine(@" | |
Summary: | |
Listen for connections on the specified port number. | |
Usage: | |
/LISTEN [port-number] | |
port-number = (optional) The Port Number to listen for | |
connections on, defaults to port 18593. | |
NOTE: Listening on more than one port is not supported. | |
NOTE: The port may need to be added to your firewall. | |
See also: /ACCEPT, /NOLISTEN | |
"); | |
break; | |
case "NOLISTEN": | |
Console.WriteLine(@" | |
Summary: | |
Stop listening for connections. | |
Usage: | |
/NOLISTEN | |
See also: /BAN, /DISCONNECT, /LISTEN | |
"); | |
break; | |
case "CONNECT": | |
Console.WriteLine(@" | |
Summary: | |
Connect a remote system. | |
Usage: | |
/CONNECT <host[:port]> | |
host = (required) a hostname or ip address of the | |
remote system to connect to. | |
port = (optional) The Port Number to listen for | |
connections on, defaults to port 18593. | |
See also: /DISCONNECT | |
"); | |
break; | |
case "DISCONNECT": | |
Console.WriteLine(@" | |
Summary: | |
Disconnect a remote system. | |
Usage: | |
/DISCONNECT <alias|thumbprint|<host[:port]>|*]> | |
Only 1 the 3 parameters shown are required: | |
alias = (required) an alias previously assigned | |
to a thumbrint associated with the remote | |
system. | |
-or- | |
thumbprint = (required) a thumbrint associated | |
with the remote system. | |
-or- | |
host = (required) a hostname or ip address of the | |
remote system to connect to. | |
port = (optional) The Port Number to listen for | |
connections on, defaults to port 18593. | |
-or- | |
* = a special-case literal character '*' (asterisk) | |
which will disconnect all remote systems. | |
See also: /BAN, /CONNECT | |
"); | |
break; | |
case "ACCEPT": | |
Console.WriteLine(@" | |
Summary: | |
Accept a thumbprint/remote/client, and optionally | |
assign it an alias, by adding it to the WHITELIST. | |
Usage: | |
/ACCEPT <thumbprint> [alias] | |
thumbprint = (required) a thumbrint associated | |
with the remote system. | |
alias = (optional) an alias previously assigned | |
to a thumbrint associated with the remote | |
system. | |
See also: /BAN | |
"); | |
break; | |
case "BAN": | |
Console.WriteLine(@" | |
Summary: | |
Ban a thumbprint/remote/client by removing it from | |
the WHITELIST. | |
Usage: | |
/BAN <alias|thumbprint|<host[:port]>> | |
alias = (required) an alias previously assigned | |
to a thumbrint associated with the remote | |
system. | |
-or- | |
thumbprint = (required) a thumbrint associated | |
with the remote system. | |
-or- | |
host = (required) a hostname or ip address of the | |
remote system to connect to. | |
port = (optional) The Port Number to listen for | |
connections on, defaults to port 18593. | |
See also: /BAN | |
"); | |
break; | |
case "WHITELIST": | |
Console.WriteLine(@" | |
Summary: | |
Displays the current WHITELIST entries. | |
Usage: | |
/WHITELIST | |
See also: /ACCEPT, /BAN | |
"); | |
break; | |
case "QUIT": | |
Console.WriteLine(@" | |
Summary: | |
Quit, gracefully disconnecting all remotes. | |
Usage: | |
/QUIT | |
"); | |
break; | |
case "HELP": | |
default: | |
Console.WriteLine(@" | |
/HELP [command] | |
/LISTEN [port-number] | |
/NOLISTEN | |
/CONNECT <host>:<port> | |
/DISCONNECT <alias|<host>:<port>> | |
/ACCEPT <thumbprint> [alias] | |
/BAN <thumbprint|alias|<host>:<port>> | |
/WHITELIST | |
/QUIT | |
"); | |
break; | |
} | |
} | |
private static void PrintHelp() => | |
Console.WriteLine(@" | |
shenc genkeys | |
generates a new keypair | |
shenc hash <keyfile> | |
gets a hash of the specified keypair | |
shenc encrypt <keyfile> <input> | |
encrypts a string or file using specified keypair | |
shenc decrypt <keyfile> <input> | |
decrypts a string or file using specified keypair | |
shenc chat [keyfile] | |
enter `shenc` into 'chat mode', a chat-specific keypair | |
is auto-generated if one is not specified (ideal.) | |
=== | |
=== NO-IP Support: | |
=== | |
=== In your app config, add two <appSettings/> keys: | |
=== | |
<appSettings> | |
<add key=""no-ip:hostname"" value=""w00tcakes.ddns.net""/> | |
<add key=""no-ip:auth"" value=""UgkUnzZvIbmSX9Fp5ejRBtgpwsTHV/g+QB0=""/> | |
<!-- optional keys, and their defaults | |
<add key=""no-ip:key"" value=""chat""/> | |
<add key=""no-ip:address"" value=""127.0.0.1""/> | |
--> | |
</appSettings> | |
=== You can create an encrypted `auth` value like so: | |
shenc e chat noip-username:noip-password | |
=== Then copy-paste the base64-encoded value into your config. | |
=== "); | |
#endregion Interactive Help | |
#region TODO: use a real logging framework | |
private static void Log(string text) | |
{ | |
Console.WriteLine(text); | |
} | |
private static void Log(Exception ex) | |
{ | |
try | |
{ | |
var prefix = ""; | |
while (ex != null) | |
{ | |
var text = $@"{prefix}Exception: {ex.GetType().FullName} | |
Message: {ex.Message} | |
StackTrace: {ex.StackTrace}"; | |
Console.Error.WriteLine(text); | |
DebugLog(text); | |
ex = ex.InnerException; | |
prefix = "Inner"; | |
} | |
} | |
catch (Exception L_ex) | |
{ | |
Trace.TraceError($"{L_ex.Message}=>{L_ex.StackTrace}"); | |
} | |
} | |
private static void DebugLog(string text) | |
{ | |
if (Debugger.IsAttached) | |
{ | |
Trace.WriteLine($"{DateTime.UtcNow:o} [{_processId}] {text}"); | |
} | |
} | |
#endregion TODO: use a real logging framework | |
private static RSA GenerateKeypair(string keyid = null, int keyLength = 8192) | |
{ | |
keyid = keyid ?? $"{Guid.NewGuid()}"; | |
if (keyid.EndsWith(".prikey", StringComparison.OrdinalIgnoreCase) || keyid.EndsWith(".pubkey", StringComparison.OrdinalIgnoreCase)) | |
{ | |
keyid = keyid.Remove(keyid.Length - 7); | |
} | |
Console.WriteLine("Generating a new key, this could take a while.. please be patient."); | |
var rsa = RSA.Create(); | |
rsa.KeySize = keyLength; | |
{ | |
var rsaParameters = rsa.ExportParameters(true); | |
var json = JsonConvert.SerializeObject(new | |
{ | |
// dynamic type because `RSAParameters` does not serialize as expected | |
rsaParameters.D, | |
rsaParameters.DP, | |
rsaParameters.DQ, | |
rsaParameters.Exponent, | |
rsaParameters.InverseQ, | |
rsaParameters.Modulus, | |
rsaParameters.P, | |
rsaParameters.Q | |
}, Formatting.Indented); | |
var prikey = Encoding.UTF8.GetBytes(json); | |
using (var file = File.Open($"{keyid}.prikey", FileMode.CreateNew, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete)) | |
{ | |
file.Write(prikey, 0, prikey.Length); | |
file.Flush(); | |
file.Close(); | |
} | |
Console.WriteLine($"Generated PRIKEY file: {keyid}.prikey"); | |
} | |
{ | |
var rsaParameters = rsa.ExportParameters(false); | |
var json = JsonConvert.SerializeObject(new | |
{ | |
// dynamic type because `RSAParameters` does not serialize as expected | |
rsaParameters.D, | |
rsaParameters.DP, | |
rsaParameters.DQ, | |
rsaParameters.Exponent, | |
rsaParameters.InverseQ, | |
rsaParameters.Modulus, | |
rsaParameters.P, | |
rsaParameters.Q | |
}, Formatting.Indented); | |
var pubkey = Encoding.UTF8.GetBytes(json); | |
using (var file = File.Open($"{keyid}.pubkey", FileMode.CreateNew, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete)) | |
{ | |
file.Write(pubkey, 0, pubkey.Length); | |
file.Flush(); | |
file.Close(); | |
} | |
Console.WriteLine($"Generated PUBKEY file: {keyid}.pubkey"); | |
} | |
return rsa; | |
} | |
private static RSA LoadKeypair(string keyid, bool generateIfMissing = false, int keyLength = 8192) | |
{ | |
if (!File.Exists(keyid)) | |
{ | |
if (File.Exists($"{keyid}.prikey")) | |
{ | |
keyid = $"{keyid}.prikey"; | |
} | |
else if (File.Exists($"{keyid}.pubkey")) | |
{ | |
keyid = $"{keyid}.pubkey"; | |
} | |
else if (generateIfMissing) | |
{ | |
keyid = $"{keyid}.prikey"; | |
return GenerateKeypair(keyid, keyLength); | |
} | |
else | |
{ | |
throw new FileNotFoundException(keyid); | |
} | |
} | |
using (var file = File.Open(keyid, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) | |
{ | |
var buf = new byte[file.Length]; | |
var offset = 0; | |
while (offset < buf.Length) | |
{ | |
offset += file.Read(buf, offset, buf.Length); | |
} | |
var json = Encoding.UTF8.GetString(buf); | |
var parameters = JsonConvert.DeserializeObject<dynamic>(json); // dynamic type because `RSAParameters` does not deserialize as expected | |
var rsaParameters = new RSAParameters | |
{ | |
D = parameters.D, | |
DP = parameters.DP, | |
DQ = parameters.DQ, | |
Exponent = parameters.Exponent, | |
InverseQ = parameters.InverseQ, | |
Modulus = parameters.Modulus, | |
P = parameters.P, | |
Q = parameters.Q | |
}; | |
var rsa = RSA.Create(); | |
rsa.ImportParameters(rsaParameters); | |
file.Close(); | |
Console.WriteLine($"Loaded Key '{keyid}'"); | |
return rsa; | |
} | |
} | |
private static void EncryptText(string keyfile, string input) | |
{ | |
var rsa = LoadKeypair(keyfile); | |
var data = Encoding.UTF8.GetBytes(input); | |
var edata = rsa.Encrypt(data, RSAEncryptionPadding.Pkcs1); | |
var output = Convert.ToBase64String(edata, Base64FormattingOptions.None); | |
Console.WriteLine(output); | |
} | |
private static void EncryptFile(string keyfile, string input) | |
{ | |
var rsa = LoadKeypair(keyfile); | |
using (var infile = File.Open($"{input}", FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) | |
{ | |
var data = new byte[infile.Length]; | |
infile.Read(data, 0, data.Length); | |
var edata = rsa.Encrypt(data, RSAEncryptionPadding.Pkcs1); | |
using (var outfile = File.Open($"{input}.out", FileMode.CreateNew, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete)) | |
{ | |
outfile.Write(edata, 0, edata.Length); | |
outfile.Flush(); | |
outfile.Close(); | |
} | |
infile.Close(); | |
} | |
Console.WriteLine($"Created: {input}.out"); | |
} | |
private static void DecryptText(string keyfile, string input) | |
{ | |
var rsa = LoadKeypair(keyfile); | |
var edata = Convert.FromBase64String(input); | |
var data = rsa.Decrypt(edata, RSAEncryptionPadding.Pkcs1); | |
var output = Encoding.UTF8.GetString(data); | |
Console.WriteLine(output); | |
} | |
private static void DecryptFile(string keyfile, string input) | |
{ | |
var rsa = LoadKeypair(keyfile); | |
using (var infile = File.Open($"{input}", FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) | |
{ | |
if (input.EndsWith(".out")) | |
{ | |
input = input.Substring(0, input.Length - 4); | |
} | |
var edata = new byte[infile.Length]; | |
infile.Read(edata, 0, edata.Length); | |
var data = rsa.Decrypt(edata, RSAEncryptionPadding.Pkcs1); | |
using (var outfile = File.Open($"{input}", FileMode.CreateNew, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete)) | |
{ | |
outfile.Write(data, 0, data.Length); | |
outfile.Flush(); | |
outfile.Close(); | |
} | |
infile.Close(); | |
} | |
Console.WriteLine($"Created: {input}"); | |
} | |
private static void Encrypt(string keyfile, string input) | |
{ | |
if (File.Exists(input)) | |
{ | |
EncryptFile(keyfile, input); | |
} | |
else | |
{ | |
EncryptText(keyfile, input); | |
} | |
} | |
private static void Decrypt(string keyfile, string input) | |
{ | |
if (File.Exists(input)) | |
{ | |
DecryptFile(keyfile, input); | |
} | |
else | |
{ | |
DecryptText(keyfile, input); | |
} | |
} | |
private sealed class ClientState : | |
IDisposable | |
{ | |
private RSA _rsa; | |
~ClientState() | |
{ | |
Dispose(false); | |
} | |
public CancellationTokenSource CancellationTokenSource { get; set; } | |
public string Alias { get; set; } | |
public string HostName { get; set; } | |
public int PortNumber { get; set; } | |
public RSA RSA | |
{ | |
get | |
{ | |
return _rsa; | |
} | |
set | |
{ | |
if (_rsa != value) | |
{ | |
_rsa = value; | |
if (_rsa != null) | |
{ | |
Thumbprint = GetThumbprint(_rsa); | |
Alias = GetAlias(Thumbprint); | |
} | |
else | |
{ | |
Thumbprint = "(null)"; | |
Alias = $"{HostName}:{PortNumber}"; | |
} | |
} | |
} | |
} | |
public string Thumbprint { get; set; } | |
public TcpClient TcpClient { get; set; } | |
public Task Worker { get; set; } | |
public override string ToString() | |
{ | |
return $"{Alias ?? Thumbprint ?? HostName + ":" + PortNumber}"; | |
} | |
public void Dispose() | |
{ | |
Dispose(true); | |
} | |
private void Dispose(bool disposing) | |
{ | |
try | |
{ | |
var rsa = RSA; | |
RSA = null; | |
if (rsa != null) | |
{ | |
rsa.Dispose(); | |
} | |
} | |
catch { /* NOP */ } | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
quick. dirty. works.