Start repo, claude & kimi still vibing tho

This commit is contained in:
ra
2026-02-14 17:04:21 -07:00
commit f5b8aeb39d
54 changed files with 9466 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
defmodule Odinsea.Net.Cipher.AESCipher do
@moduledoc """
MapleStory AES cipher implementation (AES in ECB mode with custom IV handling).
This is used for encrypting/decrypting packet data.
Ported from: src/handling/netty/cipher/AESCipher.java
"""
@block_size 1460
# MapleStory AES key (32 bytes, expanded from the Java version)
@aes_key <<
0x13, 0x00, 0x00, 0x00,
0x08, 0x00, 0x00, 0x00,
0x06, 0x00, 0x00, 0x00,
0xB4, 0x00, 0x00, 0x00,
0x1B, 0x00, 0x00, 0x00,
0x0F, 0x00, 0x00, 0x00,
0x33, 0x00, 0x00, 0x00,
0x52, 0x00, 0x00, 0x00
>>
@doc """
Encrypts or decrypts packet data in place using AES-ECB with IV.
## Parameters
- data: Binary data to encrypt/decrypt
- iv: 4-byte IV binary
## Returns
- Encrypted/decrypted binary data
"""
@spec crypt(binary(), binary()) :: binary()
def crypt(data, <<_::binary-size(4)>> = iv) when is_binary(data) do
crypt_recursive(data, iv, 0, byte_size(data), @block_size - 4)
end
# Recursive encryption/decryption function
@spec crypt_recursive(binary(), binary(), non_neg_integer(), non_neg_integer(), non_neg_integer()) :: binary()
defp crypt_recursive(data, _iv, start, remaining, _length) when remaining <= 0 do
# Return the portion of data we've processed
binary_part(data, 0, start)
end
defp crypt_recursive(data, iv, start, remaining, length) do
# Multiply the IV by 4
seq_iv = multiply_bytes(iv, byte_size(iv), 4)
# Adjust length if remaining is smaller
actual_length = min(remaining, length)
# Extract the portion of data to process
data_bytes = :binary.bin_to_list(data)
# Process the data chunk
{new_data_bytes, _final_seq_iv} =
process_chunk(data_bytes, seq_iv, start, start + actual_length, 0)
# Convert back to binary
new_data = :binary.list_to_bin(new_data_bytes)
# Continue with next chunk
new_start = start + actual_length
new_remaining = remaining - actual_length
new_length = @block_size
crypt_recursive(new_data, iv, new_start, new_remaining, new_length)
end
# Process a single chunk of data
@spec process_chunk(list(byte()), binary(), non_neg_integer(), non_neg_integer(), non_neg_integer()) ::
{list(byte()), binary()}
defp process_chunk(data_bytes, seq_iv, x, end_x, _offset) when x >= end_x do
{data_bytes, seq_iv}
end
defp process_chunk(data_bytes, seq_iv, x, end_x, offset) do
# Check if we need to re-encrypt the IV
{new_seq_iv, new_offset} =
if rem(offset, byte_size(seq_iv)) == 0 do
# Encrypt the IV using AES
encrypted_iv = aes_encrypt_block(seq_iv)
{encrypted_iv, 0}
else
{seq_iv, offset}
end
# XOR the data byte with the IV byte
seq_iv_bytes = :binary.bin_to_list(new_seq_iv)
iv_index = rem(new_offset, length(seq_iv_bytes))
iv_byte = Enum.at(seq_iv_bytes, iv_index)
data_byte = Enum.at(data_bytes, x)
xor_byte = Bitwise.bxor(data_byte, iv_byte)
# Update the data
updated_data = List.replace_at(data_bytes, x, xor_byte)
# Continue processing
process_chunk(updated_data, new_seq_iv, x + 1, end_x, new_offset + 1)
end
# Encrypt a single 16-byte block using AES-ECB
@spec aes_encrypt_block(binary()) :: binary()
defp aes_encrypt_block(block) do
# Pad or truncate to 16 bytes for AES
padded_block =
case byte_size(block) do
16 -> block
size when size < 16 -> block <> :binary.copy(<<0>>, 16 - size)
size when size > 16 -> binary_part(block, 0, 16)
end
# Perform AES encryption in ECB mode
:crypto.crypto_one_time(:aes_128_ecb, @aes_key, padded_block, true)
end
# Multiply bytes - repeats the first `count` bytes of `input` `mul` times
@spec multiply_bytes(binary(), non_neg_integer(), non_neg_integer()) :: binary()
defp multiply_bytes(input, count, mul) do
# Take first `count` bytes and repeat them `mul` times
chunk = binary_part(input, 0, min(count, byte_size(input)))
:binary.copy(chunk, mul)
end
end

View File

@@ -0,0 +1,203 @@
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
"""
use Bitwise
alias Odinsea.Net.Cipher.{AESCipher, IGCipher}
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., 342)
- 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 """
Encrypts outgoing packet data and updates the send IV.
## Parameters
- crypto: ClientCrypto state
- data: Binary packet data to encrypt
## Returns
- {updated_crypto, encrypted_data}
"""
@spec encrypt(t(), binary()) :: {t(), binary()}
def encrypt(%__MODULE__{} = crypto, data) when is_binary(data) do
# Backup current send IV
updated_crypto = %{crypto | send_iv_old: crypto.send_iv}
# Encrypt the data
encrypted_data =
if crypto.use_custom_crypt do
basic_cipher(data)
else
AESCipher.crypt(data, crypto.send_iv)
end
# Update the send IV using InnoGames hash
new_send_iv = IGCipher.inno_hash(crypto.send_iv)
final_crypto = %{updated_crypto | send_iv: new_send_iv}
{final_crypto, encrypted_data}
end
@doc """
Decrypts incoming packet data and updates the recv IV.
## 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}
# Decrypt the data
decrypted_data =
if crypto.use_custom_crypt do
basic_cipher(data)
else
AESCipher.crypt(data, crypto.recv_iv)
end
# Update the recv IV using InnoGames hash
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
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(integer(), integer()) :: integer()
def decode_header_len(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
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 -> Bitwise.bxor(byte, 0x69) end)
|> :binary.list_to_bin()
end
end

View File

@@ -0,0 +1,92 @@
defmodule Odinsea.Net.Cipher.IGCipher do
@moduledoc """
InnoGames cipher implementation for MapleStory packet encryption.
Implements the IV hashing algorithm used after each encryption operation.
Ported from: src/handling/netty/cipher/IGCipher.java
"""
use Bitwise
@doc """
Applies the InnoGames hash transformation to a 4-byte IV.
This function mutates the IV in-place using a shuffle table and bit rotation.
## Parameters
- iv: 4-byte binary IV to transform
## Returns
- Transformed 4-byte binary IV
"""
@spec inno_hash(binary()) :: binary()
def inno_hash(<<a, b, c, d>>) do
# Start with the base IV for morphing
base_iv = <<0xF2, 0x53, 0x50, 0xC6>>
# Apply morphKey for each byte of the original IV
result =
[a, b, c, d]
|> Enum.reduce(base_iv, fn value, acc ->
morph_key(acc, value)
end)
result
end
# Morph a 4-byte key using a single input byte value
@spec morph_key(binary(), byte()) :: binary()
defp morph_key(<<k0, k1, k2, k3>>, value) do
input = value &&& 0xFF
table = shuffle_byte(input)
# Apply the transformation operations
new_k0 = k0 + shuffle_byte(k1) - input
new_k1 = k1 - bxor(k2, table)
new_k2 = bxor(k2, shuffle_byte(k3) + input)
new_k3 = k3 - (k0 - table)
# Combine into 32-bit value (little-endian)
val =
(new_k0 &&& 0xFF) |||
((new_k1 <<< 8) &&& 0xFF00) |||
((new_k2 <<< 16) &&& 0xFF0000) |||
((new_k3 <<< 24) &&& 0xFF000000)
# Rotate left by 3 bits
rotated = ((val <<< 3) ||| (val >>> 29)) &&& 0xFFFFFFFF
# Extract bytes (little-endian)
<<
rotated &&& 0xFF,
(rotated >>> 8) &&& 0xFF,
(rotated >>> 16) &&& 0xFF,
(rotated >>> 24) &&& 0xFF
>>
end
# Lookup a byte in the shuffle table
@spec shuffle_byte(integer()) :: integer()
defp shuffle_byte(index) do
elem(@shuffle_table, index &&& 0xFF)
end
# Shuffle table - 256 bytes used for IV transformation
@shuffle_table {
0xEC, 0x3F, 0x77, 0xA4, 0x45, 0xD0, 0x71, 0xBF, 0xB7, 0x98, 0x20, 0xFC, 0x4B, 0xE9, 0xB3, 0xE1,
0x5C, 0x22, 0xF7, 0x0C, 0x44, 0x1B, 0x81, 0xBD, 0x63, 0x8D, 0xD4, 0xC3, 0xF2, 0x10, 0x19, 0xE0,
0xFB, 0xA1, 0x6E, 0x66, 0xEA, 0xAE, 0xD6, 0xCE, 0x06, 0x18, 0x4E, 0xEB, 0x78, 0x95, 0xDB, 0xBA,
0xB6, 0x42, 0x7A, 0x2A, 0x83, 0x0B, 0x54, 0x67, 0x6D, 0xE8, 0x65, 0xE7, 0x2F, 0x07, 0xF3, 0xAA,
0x27, 0x7B, 0x85, 0xB0, 0x26, 0xFD, 0x8B, 0xA9, 0xFA, 0xBE, 0xA8, 0xD7, 0xCB, 0xCC, 0x92, 0xDA,
0xF9, 0x93, 0x60, 0x2D, 0xDD, 0xD2, 0xA2, 0x9B, 0x39, 0x5F, 0x82, 0x21, 0x4C, 0x69, 0xF8, 0x31,
0x87, 0xEE, 0x8E, 0xAD, 0x8C, 0x6A, 0xBC, 0xB5, 0x6B, 0x59, 0x13, 0xF1, 0x04, 0x00, 0xF6, 0x5A,
0x35, 0x79, 0x48, 0x8F, 0x15, 0xCD, 0x97, 0x57, 0x12, 0x3E, 0x37, 0xFF, 0x9D, 0x4F, 0x51, 0xF5,
0xA3, 0x70, 0xBB, 0x14, 0x75, 0xC2, 0xB8, 0x72, 0xC0, 0xED, 0x7D, 0x68, 0xC9, 0x2E, 0x0D, 0x62,
0x46, 0x17, 0x11, 0x4D, 0x6C, 0xC4, 0x7E, 0x53, 0xC1, 0x25, 0xC7, 0x9A, 0x1C, 0x88, 0x58, 0x2C,
0x89, 0xDC, 0x02, 0x64, 0x40, 0x01, 0x5D, 0x38, 0xA5, 0xE2, 0xAF, 0x55, 0xD5, 0xEF, 0x1A, 0x7C,
0xA7, 0x5B, 0xA6, 0x6F, 0x86, 0x9F, 0x73, 0xE6, 0x0A, 0xDE, 0x2B, 0x99, 0x4A, 0x47, 0x9C, 0xDF,
0x09, 0x76, 0x9E, 0x30, 0x0E, 0xE4, 0xB2, 0x94, 0xA0, 0x3B, 0x34, 0x1D, 0x28, 0x0F, 0x36, 0xE3,
0x23, 0xB4, 0x03, 0xD8, 0x90, 0xC8, 0x3C, 0xFE, 0x5E, 0x32, 0x24, 0x50, 0x1F, 0x3A, 0x43, 0x8A,
0x96, 0x41, 0x74, 0xAC, 0x52, 0x33, 0xF0, 0xD9, 0x29, 0x80, 0xB1, 0x16, 0xD3, 0xAB, 0x91, 0xB9,
0x84, 0x7F, 0x61, 0x1E, 0xCF, 0xC5, 0xD1, 0x56, 0x3D, 0xCA, 0xF4, 0x05, 0xC6, 0xE5, 0x08, 0x49
}
end

View File

@@ -0,0 +1,231 @@
defmodule Odinsea.Net.Cipher.LoginCrypto do
@moduledoc """
Login cryptography utilities for MapleStory authentication.
Provides password hashing (SHA-1, SHA-512 with salt) and RSA decryption.
Ported from: src/client/LoginCrypto.java
"""
@extra_length 6
@doc """
Generates a 13-digit Asiasoft passport string.
Format: Letter + 11 digits + Letter
## Returns
- 13-character string (e.g., "A12345678901B")
"""
@spec generate_13digit_asiasoft_passport() :: String.t()
def generate_13digit_asiasoft_passport do
alphabet = ~w(A B C D E F G H I J K L M N O P Q R S T U V W X Y Z)
numbers = ~w(1 2 3 4 5 6 7 8 9)
# First letter
first = Enum.random(alphabet)
# 11 digits
middle =
1..11
|> Enum.map(fn _ -> Enum.random(numbers) end)
|> Enum.join()
# Last letter
last = Enum.random(alphabet)
"#{first}#{middle}#{last}"
end
@doc """
Hashes a password using SHA-1.
## Parameters
- password: Plain text password
## Returns
- Hex-encoded SHA-1 hash (lowercase)
"""
@spec hex_sha1(String.t()) :: String.t()
def hex_sha1(password) when is_binary(password) do
hash_with_digest(password, :sha)
end
@doc """
Hashes a password using SHA-512 with a salt.
## Parameters
- password: Plain text password
- salt: Salt string (hex-encoded)
## Returns
- Hex-encoded SHA-512 hash (lowercase)
"""
@spec hex_sha512(String.t(), String.t()) :: String.t()
def hex_sha512(password, salt) when is_binary(password) and is_binary(salt) do
hash_with_digest(password <> salt, :sha512)
end
@doc """
Checks if a SHA-1 hash matches a password.
## Parameters
- hash: Expected hash (hex string, lowercase)
- password: Password to verify
## Returns
- true if matches, false otherwise
"""
@spec check_sha1_hash?(String.t(), String.t()) :: boolean()
def check_sha1_hash?(hash, password) do
hash == hex_sha1(password)
end
@doc """
Checks if a salted SHA-512 hash matches a password.
## Parameters
- hash: Expected hash (hex string, lowercase)
- password: Password to verify
- salt: Salt used for hashing
## Returns
- true if matches, false otherwise
"""
@spec check_salted_sha512_hash?(String.t(), String.t(), String.t()) :: boolean()
def check_salted_sha512_hash?(hash, password, salt) do
hash == hex_sha512(password, salt)
end
@doc """
Verifies a salted SHA-512 hash against a password.
Returns {:ok, password} on match, {:error, :invalid} on mismatch.
## Parameters
- password: Password to verify
- salt: Salt used for hashing
- hash: Expected hash (hex string, lowercase)
## Returns
- {:ok, password} if matches
- {:error, :invalid} if doesn't match
"""
@spec verify_salted_sha512(String.t(), String.t(), String.t()) :: {:ok, String.t()} | {:error, :invalid}
def verify_salted_sha512(password, salt, hash) do
if check_salted_sha512_hash?(hash, password, salt) do
{:ok, password}
else
{:error, :invalid}
end
end
@doc """
Generates a random 16-byte salt.
## Returns
- Hex-encoded salt string (32 characters, lowercase)
"""
@spec make_salt() :: String.t()
def make_salt do
:crypto.strong_rand_bytes(16)
|> Base.encode16(case: :lower)
end
@doc """
Adds random prefix to a string (used for RSA password encoding).
## Parameters
- input: String to prefix
## Returns
- String with 6 random alphanumeric characters prepended
"""
@spec rand_s(String.t()) :: String.t()
def rand_s(input) when is_binary(input) do
alphabet = ~w(A B C D E F G H I J K L M N O P Q R S T U V W X Y Z)
numbers = ~w(1 2 3 4 5 6 7 8 9)
chars = alphabet ++ numbers
prefix =
1..@extra_length
|> Enum.map(fn _ -> Enum.random(chars) end)
|> Enum.join()
prefix <> input
end
@doc """
Removes random prefix from a string (used for RSA password decoding).
Extracts 128 characters starting from position 6.
## Parameters
- input: String with random prefix
## Returns
- Substring from position 6 to 134 (128 characters)
"""
@spec rand_r(String.t()) :: String.t()
def rand_r(input) when is_binary(input) do
String.slice(input, @extra_length, 128)
end
@doc """
Decrypts an RSA-encrypted password using the hardcoded private key.
Uses RSA/NONE/OAEPPadding with the server's private key.
## Parameters
- encrypted_password: Hex-encoded encrypted password
## Returns
- Decrypted password string, or empty string on error
"""
@spec decrypt_rsa(String.t()) :: String.t()
def decrypt_rsa(encrypted_password) when is_binary(encrypted_password) do
try do
# RSA private key parameters (from the Java version)
modulus =
107_657_795_738_756_861_764_863_218_740_655_861_479_186_575_385_923_787_150_128_619_142_132_921_674_398_952_720_882_614_694_082_036_467_689_482_295_621_654_506_166_910_217_557_126_105_160_228_025_353_603_544_726_428_541_751_588_805_629_215_516_978_192_030_682_053_419_499_436_785_335_057_001_573_080_195_806_844_351_954_026_120_773_768_050_428_451_512_387_703_488_216_884_037_312_069_441_551_935_633_523_181_351
private_exponent =
5_550_691_850_424_331_841_608_142_211_646_492_148_529_402_295_329_912_519_344_562_675_759_756_203_942_720_314_385_192_411_176_941_288_498_447_604_817_211_202_470_939_921_344_057_999_440_566_557_786_743_767_752_684_118_754_789_131_428_284_047_255_370_747_277_972_770_485_804_010_629_706_937_510_833_543_525_825_792_410_474_569_027_516_467_052_693_380_162_536_113_699_974_433_283_374_142_492_196_735_301_185_337
# Decode hex string to binary
encrypted_bytes = Base.decode16!(encrypted_password, case: :mixed)
# Create RSA private key
rsa_key = [modulus, private_exponent]
# Decrypt using RSA with OAEP padding
# Note: Elixir's :public_key module uses a different format
# We need to construct the key properly
private_key = {
:RSAPrivateKey,
:"two-prime",
modulus,
65537,
private_exponent,
# These are placeholders - full key derivation needed for production
0,
0,
0,
0,
0,
:asn1_NOVALUE
}
decrypted = :public_key.decrypt_private(encrypted_bytes, private_key, rsa_pad: :rsa_pkcs1_oaep_padding)
to_string(decrypted)
rescue
error ->
require Logger
Logger.error("RSA decryption failed: #{inspect(error)}")
""
end
end
# Private helper: hash a string with a given digest algorithm
@spec hash_with_digest(String.t(), atom()) :: String.t()
defp hash_with_digest(input, digest) do
:crypto.hash(digest, input)
|> Base.encode16(case: :lower)
end
end