To put these concepts into practice, we'll return to Talk .NET and the discovery service presented in the previous chapter. Using the discovery service, users query the server for a peer (by e-mail address), and receive an ObjRef that points to the peer. It shouldn't be possible for a malicious user to impersonate another user by logging in with the wrong e-mail address.
To prevent impersonation, you need to modify the Talk .NET server. The new server must take the following authentication steps:
When a user creates a new account, the server tests the e-mail address and verifies that the user has access to that e-mail. It then stores a record for the user that includes public key information and the e-mail address. Duplicate e-mail addresses aren't allowed.
When a user logs in for a session, the server validates the user's login request against the public key information stored in the database. The most secure way to perform this step is for the user to sign the login message using a digital signature. If the signatures can be verified with the public key information in the database, then the server can conclude that the user has access to the private key. The user is then authenticated, and a new session is started.
When a user queries the discovery service for a reference to another user, they can now be sure that this reference corresponds to the originally registered user, unless the key has been stolen.
We'll walk through the .NET cryptography code needed for this operation— and consider some of its shortcomings—over the next few sections.
The discovery service developed in Chapter 10 stores a list of unique e-mail addresses, which serve as user IDs. In this example, we'll modify the database so that the Peers table also includes public key information, as shown in Figure 11-2.
This information is stored as an XML string, because the .NET classes for asymmetric encryption provide a ToXmlString() method that can export public or private key information in a standardized format. You can then use this data to re-create the encryption object later. Here's a code snippet that demonstrates how it works:
' Create a new cryptographic object that encapsulates a new, ' dynamically generated key pair. Dim Rsa As New RSACryptoServiceProvider() ' Retrieve the full key information as a string with XML data, ' using the True parameter. Dim KeyPairInfo As String = Rsa.ToXmlString(True) ' Retrieve just the public key information as a string with XML data, ' using the False parameter. Dim PublicKeyInfo As String = Rsa.ToXmlString(False) ' Create a new duplicate RSA object and load the full key data into it. Dim RsaDuplicate As New RSACryptoServiceProvider() RsaDuplicate.FromXmlString(KeyPairInfo) ' Create a duplicate RSA object with public key information only. ' This allows you to validate signatures and encrypt data, but you can't decrypt data. Dim RsaPublicOnly As New RSACryptoServiceProvider() RsaPublicOnly.FromXmlString(PublicKeyInfo)
Note that the database table only includes the public key information. This is enough for the server to validate signatures from the user. The server should never be given access to a user's private key, because that information must be carefully protected! The peer will store the full key pair on the local computer. In our example, this information is simply saved to the peer's hard drive, which means that an attacker could impersonate the user if the attacker can steal the key file. Other approaches might be to store this data in the registry, in a secure database, or even in a custom piece of hardware. The latter provides the best security, but it's obviously very unlikely in a peer-to-peer scenario.
Along with these changes, the database class, database stored procedure, and web service need to be modified so that they store the public key XML information in the database. These changes aren't shown here because they're all very trivial. As you'll see, the tricky part comes when you need to actually use the key.
When the client first loads, it presents the user with a choice of creating a new account or using an existing one, as shown in Figure 11-3. If the user chooses to create a new account, the key information is saved to disk. If the user chooses to use an existing account, the key information is retrieved from disk. Of course, the user should not be able to create an account if the matching key already exists, or allowed to use an existing account if the key information can't be found.
The startup code is shown here. Note that the key information is stored in a file that uses the unique user ID, which is the e-mail address.
' Create the login window, which retrieves the user identifier. Dim frmLogin As New Login() ' Create the cryptography object with the key pair. Dim Rsa As New RSACryptoServiceProvider() ' Create the new remotable client object. Dim Client As ClientProcess ' Only continue if the user successfully exits by clicking OK ' (not the Cancel or Exit button). Do If Not frmLogin.ShowDialog() = DialogResult.OK Then End Try If frmLogin.CreateNew Then If File.Exists(frmLogin.UserName) Then MessageBox.Show("Cannot create new account. " & _ "Key file already exists for this user.") Else ' Generate a new key pair for this account. Rsa = New RSACryptoServiceProvider() Client = New ClientProcess(frmLogin.UserName, _ frmLogin.CreateNew, Rsa) ' Write the full key information to the hard drive. Dim fs As New FileStream(frmLogin.UserName, FileMode.Create) Dim w As New BinaryWriter(fs) w.Write(Rsa.ToXmlString(True)) w.Flush() fs.Close() Exit Do End If Else If File.Exists(frmLogin.UserName) Then ' Retrieve the full key information from the hard drive ' and use it to set the Rsa object. Dim fs As New FileStream(frmLogin.UserName, FileMode.Open) Dim r As New BinaryReader(fs) Rsa.FromXmlString(r.ReadString()) fs.Close() Client = New ClientProcess(frmLogin.UserName, _ frmLogin.CreateNew, Rsa) Exit Do Else MessageBox.Show("No key file exists for this user.") End If End If Catch Err As Exception MessageBox.Show(Err.Message) End Try Loop ' (Create and show the client form as usual).
In this example you only need to use digital signature authentication in the Login() web method. However, it would be a mistake to code this logic directly in the web method itself. In order to ensure that the logic that runs on the client is consistent with the logic that runs on the server, and in order to reuse the signing logic in other places if it becomes necessary, you should abstract this functionality in a dedicated class. This class should be placed in a separate component.
In this example, the dedicated class is called SignedObject. The SignedObject class allows you to attach a digital signature to any .NET object using serialization. Here's how the signing process works:
You define a serializable class that contains all the data you want to sign. For example, the StartSession() web method will use a serializable LoginInfo class that stores the e-mail address of the user attempting to log on.
You create and configure the serializable object in code. Then, you create the SignedObject class. The SignedObject class provides a constructor that takes any object, along with the key pair XML.
The SignedObject constructor serializes the supplied object to a byte array. It uses the key pair XML to create a new cryptography object and generate a signature.
Because SignedObject is itself serializable, you can convert the entire package, signature and all, to a stream of bytes using .NET serialization. This is necessary for web methods, because they won't allow you to use SignedObject directly as a parameter type. Instead, you'll have to use the provided Serialize() method to convert it to a byte array, and submit that to the server.
In this example, the SignedObject will be used to sign instances of the LoginInfo class, which encapsulates the information required for a user to log in. The LoginInfo class is shown here:
<Serializable()> _ Public Class LoginInfo Public EmailAddress As String Public TimeStamp As DateTime Public ObjRef As Byte() End Class
On the web-service side, these steps take place:
The server deserializes the byte array into the SignedObject, using the shared Deserialize() method.
Next, the server looks up the appropriate public key XML information, and submits it to the ValidateSignature() method. This method returns true if the newly generated computer signature matches the stored signature.
The GetObjectWithoutSignature() method can be used at any time to retrieve the inner object (in this case, the LoginInfo object). Remember, this doesn't mean the signature is valid, so before you call this method make sure to validate the signature. (Another approach would be to perform the signature validation in the GetObjectWithoutSignature() method, and throw an exception if the signatures don't match.)
Figure 11-4 shows the end-to-end process on the client and server.
The full SignedObject code is shown in the code listing that follows. Notice that data is serialized between .NET data types and binary data using the BinaryFormatter class. To create a signature with the RsaCryptoServiceProvider class, you use the SignData() method. To validate the signature, you use the VerifyData() method.
Imports System.Security.Cryptography Imports System.IO Imports System.Runtime.Serialization.Formatters.Binary <Serializable()> _ Public Class SignedObject ' Stores the signed object. Private SerializedObject As New MemoryStream() ' Stores the object's signature. Private Signature() As Byte Public Sub New(ByVal objectToSign As Object, ByVal keyPairXml As String) ' Serialize a copy of objectToSign in memory. Dim f As New BinaryFormatter() f.Serialize(Me.SerializedObject, objectToSign) ' Add the signature. Me.SerializedObject.Position = 0 Dim Rsa As New RSACryptoServiceProvider() Rsa.FromXmlString(keyPairXml) Me.Signature = Rsa.SignData(Me.SerializedObject, HashAlgorithm.Create()) End Sub Public Shared Function Deserialize(ByVal signedObjectBytes() As Byte) _ As SignedObject ' Deserialize the SignedObject. Dim ObjectStream As New MemoryStream() ObjectStream.Write(signedObjectBytes, 0, signedObjectBytes.Length) ObjectStream.Position = 0 Dim f As New BinaryFormatter() Return CType(f.Deserialize(ObjectStream), SignedObject) End Function Public Function Serialize() As Byte() ' Serialize the whole package, signature and all. Dim f As New BinaryFormatter() Dim ObjectStream As New MemoryStream() f.Serialize(ObjectStream, Me) Return ObjectStream.ToArray() End Function Public Function ValidateSignature(ByVal publicKeyXml) As Boolean ' Calculate a new signature using the supplied public key, and ' indicate whether it matches the stored signature. Dim Rsa As New RSACryptoServiceProvider() Rsa.FromXmlString(publicKeyXml) Return Rsa.VerifyData(Me.SerializedObject.ToArray(), _ HashAlgorithm.Create(), Me.Signature) End Function Public Function GetObjectWithoutSignature() As Object ' Deserialize the inner (packaged) object. Dim f As New BinaryFormatter() Me.SerializedObject.Position = 0 Return f.Deserialize(Me.SerializedObject) End Function End Class
The code in this class may appear complex, but it's vastly simpler to work with than it would be if you didn't use .NET serialization. In that case, you would have to manually calculate hash sizes, copy the hash to the end of the message bytes, and so on. Even worse, if you made a minor mistake such as miscalculating a byte offset, an error would occur.
The ClientProcess.Login() method requires some minor changes to work with the cryptographic components. The modified lines are emphasized.
Public Sub Login() ' Configure the client channel for sending messages and receiving ' the server callback. RemotingConfiguration.Configure("TalkClient.exe.config") ' Retrieve the ObjRef for this class. Dim Obj As ObjRef = RemotingServices.Marshal(Me) ' Serialize the ObjRef to a memory stream. Dim ObjStream As New MemoryStream() Dim f As New BinaryFormatter() f.Serialize(ObjStream, Obj) ' Define the login information. Dim Login As New LoginInfo() Login.EmailAddress = Me.Alias Login.ObjRef = ObjStream.ToArray() Login.TimeStamp = DiscoveryService.GetServerDateTime() ' Sign the login information. Dim Package As New SignedObject(Login, Me.Rsa.ToXmlString(True)) ' Start a new session by submitting the signed object, ' and then record the session GUID. Me.SessionID = DiscoveryService.StartSession(Package.Serialize()) End Sub
One detail we haven't addressed is the use of a timestamp. This prevents a type of exploit known as a replay attack, whereby a malicious user records network traffic and then "replays" it (copies it back into the network stream) to become authenticated later on. It's doubtful that a replay attack would succeed with this application, because the ObjRef would no longer be valid. Still, using a time-stamp tightens security. The server can check the time, and if it's set in the future or more than two minutes in the past, the server will reject the request. Of course, in order for this to work in systems in which clients could have different regional time settings (or just incorrect times), the client must retrieve the server time using the GetServerDateTime() web method.
<WebMethod()> _ Public Function GetServerDateTime() As DateTime Return DateTime.Now End Function
The StartSession method that deserializes the package, validates the time information, retrieves the public key that matches the user e-mail address from the database, and uses it to validate the signature. Assuming all these checks pass, it stores the ObjRef in the database.
<WebMethod()> _ Public Function StartSession(ByVal signedLoginInfo As Byte()) As Guid Try Dim Package As SignedObject = SignedObject.Deserialize(signedLoginInfo) Dim Login As LoginInfo = CType(Package.GetObjectWithoutSignature, _ LoginInfo) ' Check date. If DateTime.Now.Subtract(Login.TimeStamp).TotalMinutes > 2 Or _ DateTime.Now.Subtract(Login.TimeStamp).TotalMinutes < 0 Then Throw New ApplicationException("Invalid request message.") End If ' Verify the signature. Dim Peer As PeerInfo = DB.GetPeerInfo(Login.EmailAddress) If Not Package.ValidateSignature(Peer.PublicKeyXml) Then Throw New ApplicationException("Invalid request message.") End If Return DB.CreateSession(Peer.EmailAddress, Login.ObjRef) Catch err As Exception Trace.Write(err.ToString) Throw New ApplicationException("Could not create session.") End Try End Function
One side effect of using custom cryptography is the fact that the web service becomes much less generic. The design we've introduced forces clients not only to use the SignedObject class, but to know when to use it. Data is simply supplied as a byte array, so problems could occur if the client serializes the wrong type of object (or uses a different version of the cryptographic component from the one the server is using). These details must be tightly controlled, or they will quickly become a source of new headaches. Unfortunately, this is a necessary trade-off.
You may want to place the LoginInfo class into a separate assembly, and never update that assembly in order to prevent any versioning problems with serialization. Alternatively, you can write custom serialization code, which is beyond the scope of this book.
The key limitation in this design is the server, which is trusted implicitly. What happens if a malicious user is able to perform some type of IP spoofing, or intercept communication before it reaches the server? This type of attack generally requires some type of privileged network access (and thus is less common than some other attacks), but it's a significant risk in a large-scale application. The attacker then has the ability to impersonate the server and return a validated ObjRef that actually points to the wrong user.
There's no easy way around the challenge of validating the server identity. One option is for the server to sign all its response using the SignedObject class. The peer will then retrieve the response and validate the digital signature before attempting to use the ObjRef. In order for this to work, each client would need to be deployed with the information about the server's public key (perhaps stored in a configuration file). Otherwise, they would have no way to validate the signature.
Another problem is that the identity validation currently works only in one direction. In other words, a peer can validate the identity of another peer before contacting it. However, when a peer is contacted, the peer has no way to validate the user that's initiating the contact. In order to remedy this problem, the peers would need to exchange digitally signed messages. Any peer could then retrieve the public key XML for another peer from the server, and then use it to authenticate incoming messages. To ensure optimum performance, the peer XML information could be cached in memory in a local hashtable, so that the peer doesn't need to repeatedly contact the remote web service to request the same key information. (This pattern is shown in the previous chapter with the RecentClients collection.)
You should also remember that the use of signatures simply helps to ensure that a user identity remains consistent between the time it's created and the time the user starts a session. It doesn't necessarily indicate anything about the trustworthiness of the user—you need to perform those verifications before you register the user in the database. And no matter what approach you use, you're still at the mercy of a properly authenticated user who behaves improperly.
In the messaging example, the service is used to return a single piece of information: an object reference that can be used for a Remoting interaction. However, there's no reason why the server can't store additional information. For example, it might provide personal contact information for the user, or assign the user a specific set of permissions at a custom security level using a custom database. It's up to your application to retrieve and interpret this information, but the overall design is still the same.