defmodule Odinsea.Net.Cipher.ClientCrypto do @moduledoc """ Client cryptography coordinator for MapleStory packet encryption. Manages send/recv IVs, packet encryption/decryption, and header encoding/decoding. Ported from: src/handling/netty/ClientCrypto.java """ import Bitwise alias Odinsea.Net.Cipher.{AESCipher, IGCipher, ShandaCipher} defstruct [ :version, :use_custom_crypt, :send_iv, :send_iv_old, :recv_iv, :recv_iv_old ] @type t :: %__MODULE__{ version: integer(), use_custom_crypt: boolean(), send_iv: binary(), send_iv_old: binary(), recv_iv: binary(), recv_iv_old: binary() } @doc """ Creates a new ClientCrypto instance with random IVs. ## Parameters - version: MapleStory version number (e.g., 112) - use_custom_crypt: If false, uses AES encryption. If true, uses basic XOR with 0x69 ## Returns - New ClientCrypto struct """ @spec new(integer(), boolean()) :: t() def new(version, use_custom_crypt \\ false) do %__MODULE__{ version: version, use_custom_crypt: use_custom_crypt, send_iv: :crypto.strong_rand_bytes(4), send_iv_old: <<0, 0, 0, 0>>, recv_iv: :crypto.strong_rand_bytes(4), recv_iv_old: <<0, 0, 0, 0>> } end @doc """ Creates a new ClientCrypto instance from existing IVs (for server handshake). The server generates its own IVs and sends them to the client. ## Parameters - version: MapleStory version number - send_iv: 4-byte binary for send IV (server encrypts with this) - recv_iv: 4-byte binary for recv IV (server decrypts with this) ## Returns - New ClientCrypto struct """ @spec new_from_ivs(integer(), binary(), binary()) :: t() def new_from_ivs(version, send_iv, recv_iv) do %__MODULE__{ version: version, use_custom_crypt: false, send_iv: send_iv, send_iv_old: <<0, 0, 0, 0>>, recv_iv: recv_iv, recv_iv_old: <<0, 0, 0, 0>> } end @doc """ Creates a new ClientCrypto instance from client's IVs (after handshake). The IVs must be SWAPPED because: - Server's send IV = Client's recv IV - Server's recv IV = Client's send IV ## Parameters - version: MapleStory version number - client_send_iv: Client's send IV (from client's hello packet) - client_recv_iv: Client's recv IV (from client's hello packet) ## Returns - New ClientCrypto struct with properly swapped IVs """ @spec new_from_client_ivs(integer(), binary(), binary()) :: t() def new_from_client_ivs(version, client_send_iv, client_recv_iv) do # Swap the IVs: server's send = client's recv, server's recv = client's send %__MODULE__{ version: version, use_custom_crypt: false, send_iv: client_recv_iv, send_iv_old: <<0, 0, 0, 0>>, recv_iv: client_send_iv, recv_iv_old: <<0, 0, 0, 0>> } end @doc """ Encrypts outgoing packet data and updates the send IV. Applies Shanda encryption first, then AES encryption. ## Parameters - crypto: ClientCrypto state - data: Binary packet data to encrypt ## Returns - {updated_crypto, encrypted_data, header} """ @spec encrypt(t(), binary()) :: {t(), binary(), binary()} def encrypt(%__MODULE__{} = crypto, data) when is_binary(data) do # Backup current send IV updated_crypto = %{crypto | send_iv_old: crypto.send_iv} # Generate header BEFORE encryption (uses current IV) header = encode_header_len(updated_crypto, byte_size(data)) # Apply Shanda encryption first shanda_encrypted = ShandaCipher.encrypt(data) # Apply AES encryption encrypted_data = if crypto.use_custom_crypt do basic_cipher(shanda_encrypted) else AESCipher.crypt(shanda_encrypted, crypto.send_iv) end # Update the send IV using InnoGames hash (AFTER encryption) new_send_iv = IGCipher.inno_hash(crypto.send_iv) final_crypto = %{updated_crypto | send_iv: new_send_iv} {final_crypto, encrypted_data, header} end @doc """ Decrypts incoming packet data and updates the recv IV. Applies AES decryption first, then Shanda decryption. ## Parameters - crypto: ClientCrypto state - data: Binary packet data to decrypt ## Returns - {updated_crypto, decrypted_data} """ @spec decrypt(t(), binary()) :: {t(), binary()} def decrypt(%__MODULE__{} = crypto, data) when is_binary(data) do # Backup current recv IV updated_crypto = %{crypto | recv_iv_old: crypto.recv_iv} # Apply AES decryption aes_decrypted = if crypto.use_custom_crypt do basic_cipher(data) else AESCipher.crypt(data, crypto.recv_iv) end # Apply Shanda decryption decrypted_data = ShandaCipher.decrypt(aes_decrypted) # Update the recv IV using InnoGames hash (AFTER decryption) new_recv_iv = IGCipher.inno_hash(crypto.recv_iv) final_crypto = %{updated_crypto | recv_iv: new_recv_iv} {final_crypto, decrypted_data} end @doc """ Encodes the packet header (4 bytes) for outgoing packets. Returns the raw header bytes that prefix the encrypted packet. ## Parameters - crypto: ClientCrypto state - data_len: Length of the packet data (excluding header) ## Returns - 4-byte binary header """ @spec encode_header_len(t(), non_neg_integer()) :: binary() def encode_header_len(%__MODULE__{} = crypto, data_len) do <<_s0, _s1, s2, s3>> = crypto.send_iv # Calculate the encoded version new_version = -(crypto.version + 1) &&& 0xFFFF enc_version = (((new_version >>> 8) &&& 0xFF) ||| ((new_version <<< 8) &&& 0xFF00)) &&& 0xFFFF # Calculate raw sequence from send IV # Note: Using s3 and s2 (high bytes) as in Java version raw_seq = bxor((((s3 &&& 0xFF) ||| ((s2 <<< 8) &&& 0xFF00)) &&& 0xFFFF), enc_version) # Calculate raw length raw_len = (((data_len <<< 8) &&& 0xFF00) ||| (data_len >>> 8)) &&& 0xFFFF raw_len_encoded = bxor(raw_len, raw_seq) # Encode as 4 bytes << (raw_seq >>> 8) &&& 0xFF, raw_seq &&& 0xFF, (raw_len_encoded >>> 8) &&& 0xFF, raw_len_encoded &&& 0xFF >> end @doc """ Decodes the packet header to extract the data length. ## Parameters - raw_seq: 16-bit sequence number from header - raw_len: 16-bit length field from header ## Returns - Decoded packet length """ @spec decode_header_len(t(), integer(), integer()) :: integer() def decode_header_len(%__MODULE__{}, raw_seq, raw_len) do bxor(raw_seq, raw_len) &&& 0xFFFF end @doc """ Validates that the incoming packet header is correct for this connection. ## Parameters - crypto: ClientCrypto state - raw_seq: 16-bit sequence number from header ## Returns - true if valid, false otherwise """ @spec decode_header_valid?(t(), integer()) :: boolean() def decode_header_valid?(%__MODULE__{} = crypto, raw_seq) do <<_r0, _r1, r2, r3>> = crypto.recv_iv enc_version = crypto.version &&& 0xFFFF # Note: Using r2 and r3 as in Java version seq_base = ((r2 &&& 0xFF) ||| ((r3 <<< 8) &&& 0xFF00)) &&& 0xFFFF bxor((raw_seq &&& 0xFFFF), seq_base) == enc_version end @doc """ Gets the current send IV (for handshake). """ @spec get_send_iv(t()) :: binary() def get_send_iv(%__MODULE__{} = crypto), do: crypto.send_iv @doc """ Gets the current recv IV (for handshake). """ @spec get_recv_iv(t()) :: binary() def get_recv_iv(%__MODULE__{} = crypto), do: crypto.recv_iv # Basic XOR cipher with 0x69 (fallback when custom_crypt is enabled) @spec basic_cipher(binary()) :: binary() defp basic_cipher(data) do data |> :binary.bin_to_list() |> Enum.map(fn byte -> bxor(byte, 0x69) end) |> :binary.list_to_bin() end end