This is going to be a deep dive on how authentication works with Vanilla. This will also go over some of the authentication code used in the Shadowburn Project. Hopefully this post will be helpful for anybody else interested in learning about how Vanilla authenticates accounts. Another great article has been written that includes how encryption and decryption of the packet headers works, which happens after login is completed.
Vanilla Login Protocol
The Vanilla client uses the SRP6 protocol to authenticate accounts connecting to the server. When implemented properly, no password hashes need to be stored on the server or transmitted to/from the client. What does need to be stored on the server is the account’s salt and verifier.
There’s a Packet Reference below which explains all of the information that’s passed back and forth between the client and the server when the user clicks the Login
button. That, along with the SRP6 design, is just enough to get a very basic understanding of how the SRP protocol works.
Creating an Account
Before authentication can be attempted, an account needs to be setup with SRP in mind. As mentioned above, a salt and a verifier will need to be generated and stored with the account in order to authenticate with SRP.
Generating the Salt
The salt is simply 32 random bytes that should be cryptographically strong. In Elixir, this is done by calling OTP’s :crypto
module.
salt = :crypto.strong_rand_bytes(32)
That was easy enough.
Generating the Verifier
The verifier is a little more involved. The SRP design defines the verifier as:
n = A large safe prime (n = 2q+1, where q is prime)
generator = A generator modulo n
x = Hash(salt, password)
v = (generator^x) % n
Shadowburn currently uses the same constants for n
and generator
that are defined in MaNGOS. Since these values are passed to the client during authentication, they could potentially be changed in the code, however any accounts already created with the old values would no longer be able to be authenticated.
Vanilla uses sha1
for the hashing function. It also follows this formula very closely, however it defines x
slightly differently.
After applying the changes mentioned, this is the new definition.
n = 894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7
generator = 7
x = sha1(salt, sha1(upper("<username>:<password>")))
v = (generator^x) % n
The code to do this in Elixir looks very similar to the above.
hash = :crypto.hash(:sha, String.upcase(username <> ":" <> password))
x = :crypto.hash(:sha, salt <> hash))
verifier = :crypto.mod_pow(@g, x, @n)
With those two values generated and stored with the account, the account is ready for authentication.
Authentication Flows
In Shadowburn, each new connection to the authentication server starts a finite-state machine which begins in the challenge
state. There are two flows that the state machine can go through for the connection to end up in the authenticated
state. The flow that is used is determined by the first packet received from the client.
With either authentication flow, the connection cannot move backwards to a previous state. Any failed attempt to move forward will result in the connection being closed and requires reconnecting and starting from the beginning.
Logon Flow
The first authentication flow, known as Logon Flow
, verifies that the user knows the correct password and results in a session key being assigned to the session.
In this flow, the state will go from challenge
to auth_proof
to authenticated
. These state transitions are described in detail below.
Moving from challenge to auth_proof
When the server receives the Client Logon Challenge Packet, it knows that the user is attempting to authenticate using the Logon Flow.
The server first checks that the account exists and that the account or IP has not been banned. It also checks if the account is locked to a particular IP address. If it is, then it checks that the connection is coming from that IP.
The server then retrieves the account’s verifier
from the database and generates 19 random bytes which becomes private key b
. The verifier
and hard coded g
and n
is used along with b
to generate the public SRP key B
.
verifier = account.verifier
private_b = :crypto.strong_rand_bytes(19)
{public_b, _} = :crypto.generate_key(:srp, {:host, [verifier, @g, @n, :"6"]}, private_b)
After this has been generated, the server sends the client the Server Logon Challenge Packet, changes the state to auth_proof
, and waits for the client to respond with the Client Logon Proof Packet to move forward.
It can be helpful to note that at this step, the server has sent the client the server’s public SRP key
B
which is ephemeral (used only for this authentication attempt), the account’ssalt
, and the hard coded valuesg
andn
.
Moving from auth_proof to authenticated
When the server receives the Client Logon Proof Packet it needs to perform a calculation using the information it has and arrive at the same client_proof
hash that the client sent. If the hash that the server arrives at doesn’t match the hash that the client arrived at, then an invalid password was entered and we should notify the client and terminate the connection.
Once the server has the client’s public key A
, it can generate the scrambler
, which is done by hashing both the client and server’s public keys.
scrambler = :crypt.hash(:sha, public_a <> public_b)
With the scrambler
, the server can begin to generate the session
key. To start, it calls into OTP’s :crypto
module to compute the key.
s = :crypto.compute_key(:srp, public_a, {public_b, private_b}, {:host, [verifier, @n, :"6", scrambler]})
session = interleave(s)
After this, it performs an interleave operation on the result, which takes the binary s
and splits it into two binaries. The first contains all of the bytes in the even offsets, the the second contains all of the bytes in the odd offsets.
s = <<1, 2, 3, 4, 5, 6>>
t1 = <<1, 3, 5>>
t2 = <<2, 4, 6>>
It then hashes those two lists using sha1
and zips them together, so that the first byte from t1_hash
is next to the first byte of t2_hash
, until both hashes are interleaved.
t1_hash = <<1, 2, 3, 4, 5>>
t2_hash = <<5, 4, 3, 2, 1>>
interleaved = <<1, 5, 2, 4, 3, 3, 4, 2, 5, 1>>
This is what the interleave function looks like in Shadowburn.
defp interleave(s) do
list = Binary.to_list(s)
t1 = Binary.from_list(interleave_t1(list))
t2 = Binary.from_list(interleave_t2(list))
t1_hash = Binary.to_list(:crypto.hash(:sha, t1))
t2_hash = Binary.to_list(:crypto.hash(:sha, t2))
Binary.from_list(List.flatten(Enum.map(List.zip([t1_hash, t2_hash]), &Tuple.to_list/1)))
end
defp interleave_t1([a, _ | rest]), do: [a | interleave_t1(rest)]
defp interleave_t1([]), do: []
defp interleave_t2([_, b | rest]), do: [b | interleave_t2(rest)]
defp interleave_t2([]), do: []
At this point, the server has the session
key. It now needs to perform some additional operations in order to get to a hash that it can compare to the hash the client sent as proof that it too has the session
key.
To do this, the server takes the hash of n
and the hash of g
and does an xor operation on them called t3
. It then gets the hash of the username
called t4
. Finally, it concatenates t3
, t4
, salt
, A
, B
, and session
together and does a final hash.
mod_hash = :crypto.hash(:sha, @n)
generator_hash = :crypto.hash(:sha, @g)
t3 = :crypto.exor(mod_hash, generator_hash)
t4 = :crypto.hash(:sha, username)
m = :crypto.hash(:sha, t3 <> t4 <> salt <> public_a <> public_b <> session)
If this hash m
matches the client_proof
that was sent by the client, then the server knows that the user has entered the correct password for this account.
It then generates a final hash called the server_proof
which it sends back to the client in the Server Logon Proof Packet and changes the state to authenticated
.
server_proof = :crypto.hash(:sha, public_a <> client_proof <> session)
Reconnection Flow
The second authentication flow, known as the Reconnection Flow
, verifies that the client knows the session key.
In this flow, the state will go from challenge
to recon_proof
to authenticated
. These state transitions are described in detail below.
Moving from challenge to recon_proof
When the server receives the Client Reconnection Challenge Packet, it knows that the user is attempting to authenticate using the Logon Flow.
The server first checks that the account exists and that the account or IP has not been banned. It also checks if the account is locked to a particular IP address. If it is, then it checks that the connection is coming from that IP.
The server then generates 16 random bytes known as the challenge_data
and sends it to the client in the Server Reconnection Challenge Packet. The server changes the state to recon_proof
and waits for the client to respond with the Client Reconnection Proof Packet.
challenge_data = :crypto.strong_rand_bytes(16)
It can be helpful to note that at this step, the server has only sent the client the 16 random bytes that the client will combine with other information that will be known to both the client and the server to prove that they have the session key.
Moving from recon_proof to authenticated
When the client receives the Server Reconnection Challenge Packet it generates random 16 bytes of it’s own which we refer to as the proof_data
and sends that back, along with the client_proof
back to the server in the Client Reconnection Proof Packet.
When the server receives the Client Reconnection Proof Packet it performs the same computation that the client did with the session key that it has stored for the account.
server_proof = :crypto.hash(:sha, username <> proof_data <> challenge_data <> session)
If the server_proof
matches the client_proof
, then the server knows that the client has the session key. The server changes the state to authenticated
and notifies that client that everything succeeded with the Server Reconnection Proof Packet.
Authentication Achieved
Now that the account has been authenticated, the client will send a Client Realm List Packet to request a list of realms from the server. This request will come from the client periodically, every 15 seconds or so. The server will build up a response for the client to display.
realm_packets = Enum.map(realms, fn({realm, character_count}) ->
<< realm.realm_type :: little-size(32) >> <>
<< realm.flags :: size(8) >> <>
realm.name <> << 0 >> <>
realm.address <> << 0 >> <>
<< realm.population / realm.max_population :: little-float-size(32) >> <> # population
<< character_count || 0>> <> # number of characters
<< realm.timezone :: size(8) >> <>
<< 0 >> # unknown
end)
realm_list = Enum.reduce(realm_packets, << >>, fn(realm_packet, acc) -> acc <> realm_packet end)
realm_count = Enum.count(realms)
# packet size unknown # of realms
packet = << 16, byte_size(realm_list) + 7 :: little-size(16), 0, 0, 0, 0, realm_count >> <> realm_list <> << 2, 0 >>
Packet Reference
This packet reference has been copied from WoWDev and has been modified to add more clarity.
Client Logon Challenge Packet
Offset | Type | Name | Description |
---|---|---|---|
0x00 | uint8 | command | 0x00 |
0x01 | uint8 | error | |
0x02 | uint16 | packet size | Length of the packet, minus 4 (the size of command, error, and packet size) |
0x04 | char[4] | game | ‘WoW’ |
0x08 | uint8[3] | version | 0x01 0x01 0x02 |
0x0B | uint16 | build | 4125 |
0x0D | char[4] | platform | eg. ‘x86’ |
0x11 | char[4] | os | eg. ‘Win’ |
0x15 | char[4] | country | eg. ‘enUS’ |
0x19 | uint32 | worldregion_bias | offset in minutes from UTC time, eg. 180 means 180 minutes |
0x1D | uint32 | ip | |
0x21 | uint8 | account_name_length | |
0x22 | char[account_name_length] | account_name |
Server Logon Challenge Packet
Offset | Type | Name | Description |
---|---|---|---|
0x00 | uint8 | command | 0x00 |
0x01 | uint8 | unknown | 0x00 |
0x02 | uint8 | error code | Error Codes |
0x03 | char[32] | B | SRP public server key (ephemeral) |
0x23 | uint8 | g length | SRP generator length |
0x24 | uint8 | g | SRP generator |
0x25 | uint8 | n length | SRP modulus length |
0x26 | char[32] | n | SRP modulus |
0x46 | char[32] | srp salt | SRP user’s salt |
0x66 | char[16] | crc salt | A salt to be used in Client Logon Proof. |
Client Logon Proof Packet
Offset | Type | Name | Description |
---|---|---|---|
0x00 | uint8 | command | 0x01 |
0x01 | uint8[32] | A | SRP public client key (ephemeral) |
0x21 | uint8[20] | client_proof | |
0x35 | uint8[20] | crc_hash | |
0x49 | uint8 | num_keys |
Server Logon Proof Packet
Offset | Type | Name | Description |
---|---|---|---|
0x00 | uint8 | command | 0x01 |
0x01 | uint8 | error | Error Codes |
0x02 | uint8[20] | server_proof | |
0x16 | uint32 | unknown |
Client Reconnection Challenge Packet
This packet is the same as the Client Logon Challenge Packet, except the command is 0x02
.
Offset | Type | Name | Description |
---|---|---|---|
0x00 | uint8 | command | 0x02 |
0x01 | uint8 | error | |
0x02 | uint16 | packet size | Length of the packet, minus 4 (the size of command, error, and packet size) |
0x04 | char[4] | game | ‘WoW’ |
0x08 | uint8[3] | version | 0x01 0x01 0x02 |
0x0B | uint16 | build | 4125 |
0x0D | char[4] | platform | eg. ‘x86’ |
0x11 | char[4] | os | eg. ‘Win’ |
0x15 | char[4] | country | eg. ‘enUS’ |
0x19 | uint32 | worldregion_bias | offset in minutes from UTC time, eg. 180 means 180 minutes |
0x1D | uint32 | ip | |
0x21 | uint8 | account_name_length | |
0x22 | char[account_name_length] | account_name |
Server Reconnection Challenge Packet
Offset | Type | Name | Description |
---|---|---|---|
0x00 | uint8 | command | 0x02 |
0x01 | uint8 | error | Error Codes |
0x02 | char[16] | challenge_data | random data, used as a challenge |
0x12 | uint64 | unknown | |
0x1A | uint64 | unknown |
Client Reconnection Proof Packet
Offset | Type | Name | Description |
---|---|---|---|
0x00 | uint8 | command | 0x03 |
0x01 | char[16] | proof data | |
0x11 | char[20] | client proof | |
0x25 | char[20] | unknown hash | |
0x39 | uint8 | unknown |
Server Reconnection Proof Packet
Offset | Type | Name | Description |
---|---|---|---|
0x00 | uint8 | command | 0x03 |
0x01 | uint8 | error | Error Codes |
Client Realm List Packet
Offset | Type | Name | Description |
---|---|---|---|
0x00 | unint8 | command | 0x10 |
0x01 | uint32 | unknown | null |
Server Realm List Packet
The server responds with a packet composed of a Realm Information Header, as many Realm Information items as specified, and the Realm Information Footer.
Realm Information Header
Offset | Type | Name | Description |
---|---|---|---|
0x00 | uint8 | command | 0x10 |
0x01 | uint16 | size | size of the rest of the packet, minus 3 (the size of command and size) |
0x03 | uint32 | unknown | null |
0x07 | uint8 | number of realms | Number of realms in the packet |
Realm Information
Offset | Type | Name | Description |
---|---|---|---|
0x00 | uint32 | realm_type | 0 is normal, 1 is PVP |
0x04 | uint8 | flags | Realm Flags |
0x05 | char[] | name | Name of the realm (zero terminated string) |
char[] | address | Address of the realm “ip:port” (zero terminated string) | |
float | population | Population value. | |
uint8 | num_chars | The number of characters the account has on the server. | |
uint8 | time_zone | ||
uint8 | unknown |
Realm Information Footer
Offset | Type | Name | Description |
---|---|---|---|
0x00 | uint16 | unknown |
Realm Flags
Flags | Meaning |
---|---|
0x01 | Color the realm name in red. |
0x02 | Realm is offline. |
Op Codes
Name | Value | Notes |
---|---|---|
LOGIN_CHALL | 0x00 | |
LOGIN_PROOF | 0x01 | |
RECON_CHALL | 0x02 | |
RECON_PROOF | 0x03 | |
REALM_LIST | 0x10 | Asks for a list of realms |
XFER_INITIATE | 0x30 | unused |
XFER_DATA | 0x31 | unused |
Error Codes
Name | Value |
---|---|
LOGIN_OK | 0x00 |
LOGIN_FAILED | 0x01 |
LOGIN_FAILED2 | 0x02 |
LOGIN_BANNED | 0x03 |
LOGIN_UNKNOWN_ACCOUNT | 0x04 |
LOGIN_UNKNOWN_ACCOUNT3 | 0x05 |
LOGIN_ALREADYONLINE | 0x06 |
LOGIN_NOTIME | 0x07 |
LOGIN_DBBUSY | 0x08 |
LOGIN_BADVERSION | 0x09 |
LOGIN_DOWNLOAD_FILE | 0x0A |
LOGIN_FAILED3 | 0x0B |
LOGIN_SUSPENDED | 0x0C |
LOGIN_FAILED4 | 0x0D |
LOGIN_CONNECTED | 0x0E |
LOGIN_PARENTALCONTROL | 0x0F |
LOGIN_LOCKED_ENFORCED | 0x10 |