class Whatsapp::DecryptionService require 'openssl' require 'base64' require 'net/http' # HKDF Info strings for different media types (WhatsApp protocol) INFO_STRINGS = { image: 'WhatsApp Image Keys', video: 'WhatsApp Video Keys', audio: 'WhatsApp Audio Keys', document: 'WhatsApp Document Keys', sticker: 'WhatsApp Image Keys' }.freeze def initialize(media_url, media_key, media_type) @media_url = media_url @media_key = Base64.decode64(media_key) @media_type = media_type.to_sym @info = INFO_STRINGS[@media_type] || INFO_STRINGS[:document] end def decrypt return nil unless @media_url && @media_key # 1. Download encrypted bytes encrypted_bytes = download_content return nil unless encrypted_bytes && encrypted_bytes.bytesize > 10 Rails.logger.info "WuzAPI Decrypt: Downloaded #{encrypted_bytes.bytesize} bytes" # 2. Derive keys using HKDF SHA-256 (112 bytes total) expanded_key = OpenSSL::KDF.hkdf( @media_key, salt: ''.b, # Empty binary string info: @info, length: 112, hash: 'sha256' ) # 3. Split derived key iv = expanded_key[0...16] cipher_key = expanded_key[16...48] # mac_key = expanded_key[48...80] # For verification (optional) # ref_key = expanded_key[80...112] # Not used # 4. WhatsApp file structure: [Encrypted Content] + [MAC (10 bytes)] # Remove the last 10 bytes (MAC) cipher_text = encrypted_bytes[0...-10] # 5. Try AES-256-CBC first (older WhatsApp versions) decrypted = try_aes_cbc(cipher_key, iv, cipher_text) # 6. If CBC fails, try CTR mode (some implementations use this) decrypted ||= try_aes_ctr(cipher_key, iv, cipher_text) return nil unless decrypted # 7. Validate that we got a valid image (check magic bytes) if valid_media?(decrypted) Rails.logger.info 'WuzAPI Decrypt: SUCCESS - Valid media detected' StringIO.new(decrypted) else Rails.logger.warn 'WuzAPI Decrypt: Decrypted but invalid media format' nil end rescue StandardError => e Rails.logger.error "WuzAPI Decrypt Error: #{e.class} - #{e.message}" nil end private def try_aes_cbc(key, iv, data) decipher = OpenSSL::Cipher.new('AES-256-CBC') decipher.decrypt decipher.key = key decipher.iv = iv decipher.padding = 0 # WhatsApp doesn't use PKCS7 padding decipher.update!(data) + decipher.final rescue OpenSSL::Cipher::CipherError => e Rails.logger.debug { "AES-CBC failed: #{e.message}" } nil end def try_aes_ctr(key, iv, data) decipher = OpenSSL::Cipher.new('AES-256-CTR') decipher.decrypt decipher.key = key decipher.iv = iv decipher.update!(data) + decipher.final rescue OpenSSL::Cipher::CipherError => e Rails.logger.debug { "AES-CTR failed: #{e.message}" } nil end def valid_media?(data) return false if data.nil? || data.bytesize < 4 bytes = data.bytes[0..7] # JPEG: FF D8 FF return true if bytes[0..2] == [0xFF, 0xD8, 0xFF] # PNG: 89 50 4E 47 return true if bytes[0..3] == [0x89, 0x50, 0x4E, 0x47] # WebP: RIFF....WEBP return true if data[0..3] == 'RIFF' && data[8..11] == 'WEBP' # MP4/MOV: ftyp return true if data[4..7] == 'ftyp' # MP3: ID3 or FF FB/FF FA return true if data[0..2] == 'ID3' || bytes[0..1] == [0xFF, 0xFB] || bytes[0..1] == [0xFF, 0xFA] # OGG: OggS return true if data[0..3] == 'OggS' # PDF: %PDF return true if data[0..3] == '%PDF' false end def download_content uri = URI.parse(@media_url) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = (uri.scheme == 'https') http.verify_mode = OpenSSL::SSL::VERIFY_NONE http.open_timeout = 10 http.read_timeout = 30 request = Net::HTTP::Get.new(uri.request_uri) response = http.request(request) response.is_a?(Net::HTTPSuccess) ? response.body.b : nil rescue StandardError => e Rails.logger.error "WuzAPI Decrypt Download Error: #{e.message}" nil end end