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