Dienstag, 28. April 2015

C# Chat Client v3 - Challenge Response Authentifizierung

Ich möchte hier auf den in einem vorigen Post vorgestellten Chat Client aufbauen und eine dort erwähnte Sicherheitslücke schließen.
In der vorigen Version wählte der Benutzer beim Erstellen seines Accounts ein Passwort, dieses wurde dann an den Server gesendet und gehasht in der Datenbank gespeichert. Bei jedem Login Vorgang sendete der Client das Passwort des Benutzers wieder mit, der Server verglich dieses mit dem gespeicherten und konnte so den Benutzer authentifizieren.
Nun kann jedoch jeder mit Zugriff auf den Netzwerkverkehr, wie zum Beispiel ein Benutzer im gleichen Netzwerk oder ein Angreifer mit Zugriff auf Internetknoten u.ä., die gesendete Login Nachricht einfach mitlesen, und entweder das Passwort auslesen oder einfach die gleiche Nachricht noch einmal senden, um sich korrekt zu authentifizieren. Deshalb würde zum Beispiel auch das Verschlüsseln der Nachricht bzw. des Passworts nichts bringen, wir müssen uns etwas anders überlegen.
Die Standardwahl wäre die Verwendung von SSL / TLS, ein Verschlüsselungsprotokoll zur sicheren Datenübertragung. Hierbei wird (u.A., und je nach Verwendungsart) mittels Public - Key Verfahren ein Schlüssel ausgehandelt, mit welchem die Kommunikation verschlüsselt wird und somit nicht mehr mitlesbar ist (auch ist der Schlüssel immer anders, die Login Nachrichten sind also immer verschieden). Für TLS wird jedoch ein Zertifikat benötigt, welches den Server authentifiziert. Dieses hat wohl nicht jeder und es wird nicht unbedingt kostenlos zu erhalten sein.
Um die einfache und allgemeine Verwendbarkeit des Clients zu gewährleisten, verzichte ich daher auf TLS und implementiere selber ein kleines, ebenfalls auf Public - Key Kryptographie basierendes, Verfahren: Eine sogenannte Challenge - Response Authentifizierung. Als kurze Einführung in die asymmetrische Kryptographie kann ich den Post über RSA empfehlen.
Eine Challenge - Response Authentifizierung ist, wie der Name schon sagt, ein Authentifizierungsverfahren, bei welchem der Server dem Client eine Aufgabe (Challenge) stellt, die der Client lösen muss (Response). Dies kann zum Beispiel, wie hier, mit einem Public - Key Verfahren umgesetzt werden.
Die Idee: Bei der Erzeugung eines neuen Benutzeraccounts wird ein neues RSA Schlüsselpaar erzeugt. Der öffentliche Schlüssel wird an den Server geschickt, dieser speichert es neben dem Benutzer in der Datenbank. Der private Schlüssel wird auf dem PC des Benutzers gespeichert, allerdings AES verschlüsselt mit einem selber gewählten Passwort. Möchte sich der Benutzer nun einloggen, lädt er zuerst seinen privaten Schlüssel durch Eingabe seines Passworts aus der Datei. Er meldet sich dann beim Server, welcher einen Zufallsstring (hier eine Kombination aus Benutzernamen, aktueller Uhrzahl und 16 Bit Zufallszahl) mit dem öffentlichen Schlüssel des Benutzers verschlüsselt und diesen an ihn schickt. Der Benutzer kann diesen nun mit seinem privaten Schlüssel entschlüsseln und schickt den String zurück. Der Server vergleicht die beiden Strings und loggt den Benutzer bei Gleichheit ein. Denn: Unter der Annahme, dass RSA sicher ist, kann nur der richtige Benutzer den gesendeten String entschlüsseln. Auch können alle Nachrichten nun beliebig mitgehört werden ohne die Sicherheit zu gewährleisten, da jedes Mal ein anderer String die richtige Login Antwort ist und der öffentliche Schlüssel bekannt sein darf (Es gibt immer noch Angriffsmöglichkeiten, allerdings ist dieses Verfahren schon deutlich sicherer als das vorher verwendete - eine 100% Sicherheit kann eh nicht garantiert werden.).
Insbesondere muss ich hier darauf hinweisen, dass dieses Verfahren allein erstmal gar nichts bringt - ohne zusätzliche Methoden kann eine solche Replay Attacke einfach wiederholt werden. Zwar ist der eigentliche Login Vorgang jetzt sicher, allerdings können die Nachrichten eines per PHP Session eingeloggten Benutzers einfach wiederholt werden - in diesen ist immer Session ID und / oder Cookie enthalten, welche ausgelesen werden kann oder die Nachricht einfach wiederholt werden kann. Dann kann der Server den tatsächlich eingeloggten Benutzer und den Angreifer nicht unterscheiden. Wie schon erwähnt, TLS löst dieses Problem (dann würde allerdings auch schon die einfache Login Methode reichen), und im nächsten Post über den Client erwähne ich eine selbstgemachte Lösung.

Der Code: Wie im Post zur RSA Verschlüsselung mit PHP beschrieben, laden wir zuerst die Bibliothek phpseclib herunter und laden diese dann in den Ordner unserer PHP Skripte hoch.
Das Skript register.php ist gleich geblieben, nur dass ein Parameter jetzt pubkey statt password heißt:

<?php
include("connect.php");

$username = $_POST["username"];
$pubkey = $_POST["pubkey"];

$stmt = $conn->prepare("SELECT username FROM Users WHERE username = ?");
$stmt->bind_param("s", $username);
$stmt->execute();

$stmt->store_result();
$num_rows = $stmt->num_rows;

if ($num_rows > 0) {
     echo "Existing";
}
else {
     $stmt = $conn->prepare("INSERT INTO Users (username, pubkey) VALUES (?, ?);");
     $stmt->bind_param("ss", $username, $pubkey);
     $stmt->execute();
     echo "Success";
}
?>

Zusätzlich gibt es noch ein Skript checkuser.php zum Testen, ob der Benutzer schon existiert, damit auf dem lokalen PC nicht zu früh ein Private Key File angelegt wird:

<?php
include("connect.php");

$username = $_POST["username"];
$password = $_POST["password"];

$stmt = $conn->prepare("SELECT username FROM Users WHERE username = ?");
$stmt->bind_param("s", $username);
$stmt->execute();

$stmt->store_result();
$num_rows = $stmt->num_rows;

if ($num_rows > 0) {
     echo "Yes";
}
else {
     echo "No";
}
?>

Der Login besteht nun aus 2 Teilen, zuerst wird das Skript prelogin.php aufgerufen:

<?php

include("connect.php");
include('Crypt/RSA.php');
session_start();

$username = $_POST["username"];

$stmt = $conn->prepare("SELECT pubkey FROM Users WHERE username = ? LIMIT 1");
$stmt->bind_param("s", $username);
$stmt->execute();

$stmt->bind_result($pubkey);
$row = $stmt->fetch();


$rsa = new Crypt_RSA();
$rsa->loadKey($pubkey, CRYPT_RSA_PUBLIC_FORMAT_XML);
$challenge = $username.date("Y/m/d").date("h:i:sa").rand(0, 65536);

$_SESSION["username"] = $username;
$_SESSION["challenge"] = $challenge;

echo base64_encode($rsa->encrypt($challenge));
?>

Als erstes wird der bei der Registrierung gespeicherte öffentliche Schlüssel des angegebenen Benutzers aus der Datenbank geladen. Mit diesem verschlüsseln wir dann die Challenge und geben diese aus. Wir setzen nun aber auch schon den Benutzernamen der Session und speichern zusätzlich die Challenge in der Session, um später die korrekte Antwort überprüfen zu können.

Die Login.php sieht so aus:

<?php
session_start();

include("connect.php");

$username = $_POST["username"];
$challenge = $_POST["challenge"];

if($challenge == $_SESSION["challenge"] && isset($_SESSION["challenge"]) && ($_SESSION["username"] == $username)) {
    $_SESSION["LoggedIn"] = true;
    echo "LoginGood";
}
else {
    echo "LoginBad";
}
?>

Die übergebene entschlüsselte Challenge wird mit der gespeicherten verglichen und bei Übereinstimmung wird der Benutzer durch Setzen der Session Variable LoggedIn eingeloggt. Wichtig ist hier auch die Abfrage auf leer, da sonst ein Angreifer eventuell eine neue Session mit einem beliebigen Benutzernamen (und damit leerer Challenge) aufmachen könnte!
Die Skripte send.php und receive.php sind die gleichen, nur dass in diesen die Session Variable LoggedIn geprüft wird.

send.php:

<?php
session_start();

include("connect.php");

$Recipient = $_POST["Recipient"];
$Message = $_POST["Message"];
$Sender = $_SESSION['username'];

if(!isset($_SESSION['LoggedIn'])) {
     echo "Login first.";
     exit;
}

$stmt = $conn->prepare("INSERT INTO Messages () VALUES (?, ?, ?);");
$stmt->bind_param("sss", $Sender, $Recipient, $Message);
$stmt->execute();
    
?>

receive.php:

<?php

session_start();

include("connect.php");

if(!isset($_SESSION['LoggedIn']))
   {
   echo "Bitte erst login";
   exit;
   }
    
$Recipient = $_SESSION['username'];

$stmt = $conn->prepare("SELECT Sender, Message FROM Messages WHERE Recipient = ?");
$stmt->bind_param("s", $Recipient);
$stmt->execute();
    
$stmt->bind_result($sender, $message);
while($row = $stmt->fetch())
   {
          echo "$sender<br />";
          echo "$message<br />";
          $stmt->bind_result($sender, $message);
   }

$stmt = $conn->prepare("DELETE FROM Messages WHERE Recipient = ?");
$stmt->bind_param("s", $Recipient);
$stmt->execute();
?>

Beim C# Code werde ich nur die geänderten Stellen zeigen. Der Großteil ist natürlich gleich, insbesondere die komplette Client Oberfläche und ein Großteil der Klasse SimpleChatClient.
Zu dieser ist die Klasse Crypto hinzugekommen:

public static class Crypto
        {
            static public byte[] RSAEncrypt(byte[] DataToEncrypt, RSAParameters RSAKeyInfo)
            {
                byte[] encryptedData;

                RSACryptoServiceProvider RSA = new RSACryptoServiceProvider();
                RSA.ImportParameters(RSAKeyInfo);

                encryptedData = RSA.Encrypt(DataToEncrypt, true);

                return encryptedData;
            }

            static public byte[] RSADecrypt(byte[] DataToDecrypt, RSAParameters RSAKeyInfo)
            {
                byte[] decryptedData;

                RSACryptoServiceProvider RSA = new RSACryptoServiceProvider();
                RSA.ImportParameters(RSAKeyInfo);

                decryptedData = RSA.Decrypt(DataToDecrypt, true);

                return decryptedData;
            }

            static public void CreateSymmetricKey(string password, string salt, out byte[] Key, out byte[] IV)
            {
                if (salt.Length < 8)
                    salt = salt.PadRight(8);

                Rfc2898DeriveBytes Generator = new Rfc2898DeriveBytes(password, System.Text.Encoding.UTF8.GetBytes(salt), 10000);
                Key = Generator.GetBytes(16);
                IV = Generator.GetBytes(16);
            }

            static public string  CreateRSA(string filename, string password)
            {
                RSACryptoServiceProvider RSA = new RSACryptoServiceProvider();
                byte[] Key;
                byte[] IV;
                CreateSymmetricKey(password, filename, out Key, out IV);
                string RSAKey = RSA.ToXmlString(true);
                byte[] EncryptedKey = AESEncode(RSAKey, Key, IV);

                if (!Directory.Exists("users"))
                    Directory.CreateDirectory("users");

                if (File.Exists("users/" + filename))
                    return "Existing";

                File.WriteAllBytes("users/" + filename, EncryptedKey);
                return RSA.ToXmlString(false);
            }

            static public RSACryptoServiceProvider GetRSA(string filename, string password)
            {
                byte[] EncryptedKey = File.ReadAllBytes("users/" + filename);
                byte[] Key;
                byte[] IV;
                CreateSymmetricKey(password, filename, out Key, out IV);
                string RSAKey = AESDecode(EncryptedKey, Key, IV);
                RSACryptoServiceProvider RSA = new RSACryptoServiceProvider();
                RSA.FromXmlString(RSAKey);
                return RSA;
            }

            static public string AESDecode(byte[] encryptedBytes, byte[] key, byte[] IV)
            {
                Rijndael AESCrypto = Rijndael.Create();

                AESCrypto.Key = key;
                AESCrypto.IV = IV;

                MemoryStream ms = new MemoryStream();
                CryptoStream cs = new CryptoStream(ms, AESCrypto.CreateDecryptor(), CryptoStreamMode.Write);

                cs.Write(encryptedBytes, 0, encryptedBytes.Length);
                cs.Close();

                byte[] DecryptedBytes = ms.ToArray();
                return System.Text.Encoding.UTF8.GetString(DecryptedBytes);
            }

            static public byte[] AESEncode(string plaintext, byte[] key, byte[] IV)
            {
                Rijndael AESCrypto = Rijndael.Create();
                AESCrypto.Key = key;
                AESCrypto.IV = IV;

                MemoryStream ms = new MemoryStream();
                CryptoStream cs = new CryptoStream(ms, AESCrypto.CreateEncryptor(), CryptoStreamMode.Write);

                byte[] PlainBytes = System.Text.Encoding.UTF8.GetBytes(plaintext);
                cs.Write(PlainBytes, 0, PlainBytes.Length);
                cs.Close();

                byte[] EncryptedBytes = ms.ToArray();
                return EncryptedBytes;
            }
        }

Die Funktionen AESEncode(), AESDecode(), RSAEncrypt() and RSADecrypt() sind aus den vorigen Posts zu AES und RSA bekannt. Die Funktion CreateSymmetricKey() leitet nach dem PBKDF2 Verfahren aus dem übergebenen Passwort einen Verschlüsselungskey und IV ab und gibt diese zurück. Die Funktion CreateRSA() wird bei der Registrierung eines neuen Benutzers aufgerufen, hierbei wird ein Schlüsselpaar für RSA erzeugt. Mit dem vom Benutzer gewählten Passwort wird CreateSymmetricKey() aufgerufen und mit den zurückgegebenen Daten der private Schlüssel AES verschlüsselt in eine lokale Datei geschrieben. Die Funktion GetRSA() wird beim Login aufgerufen, sie liest und entschlüssel den privaten Key aus dieser Datei.
Die Funktion Register() wurde in der einleuchtenden Weise angepasst:

public string Register(string username, string password)
{
    // register a new user
    if (HTTPPost(ServerUrl + "checkuser.php", "username=" + username) == "No")
    {
        string RSAPubKey = Crypto.CreateRSA(username, password);
        if (RSAPubKey == "Existing")
            return "Password file already existing on computer.";
        return HTTPPost(ServerUrl + "register.php", "username=" + username + "&pubkey=" + Uri.EscapeDataString(RSAPubKey));
    }
    else
        return "Already existing.";
}

Interessant ist vielleicht noch die Funktion Login():

public bool Login(string username, string password)
{
    // login
    Cookie = new CookieContainer();
    string Challenge = HTTPPost(ServerUrl + "prelogin.php", "username=" + username);
    RSA = Crypto.GetRSA(username, password);
    string ClearChallenge = Encoding.UTF8.GetString(Crypto.RSADecrypt(Convert.FromBase64String(Challenge), RSA.ExportParameters(true)));
    string Login = HTTPPost(ServerUrl + "login.php", "username=" + username + "&challenge=" + Uri.EscapeDataString(ClearChallenge));

    if (Login == "LoginGood")
    {
        CurrentUser = new User(username);
        return true;
    }
    else
    {
        Cookie = null;
        return false;
    }
}

Zuerst wird das Skript prelogin.php aufgerufen und die Challenge ausgelesen. Dann wird der private RSA Schlüssel geladen und die Challenge mit diesem entschlüsselt. Schließlich wird das Ergebnis als Base64 String zurückgeschickt und der Benutzer dadurch authentifiziert.

Die Server URL ist http://bloggeroliver.bplaced.net/Chat/V3/.

Das ganze Projekt, inklusive der Skripte, kann hier heruntergeladen werden.

Keine Kommentare:

Kommentar veröffentlichen