Module: MixinBot::API::EncryptedMessage
- Included in:
- MixinBot::API
- Defined in:
- lib/mixin_bot/api/encrypted_message.rb
Instance Method Summary collapse
-
#base_encrypted_message_params(options) ⇒ Object
base format of message params.
-
#cached_encrypted_sessions(store, recipient_id) ⇒ Object
Read a recipient's sessions from the store.
-
#decrypt_message(data, sk: nil, si: nil) ⇒ Object
rubocop:disable Naming/MethodParameterName.
-
#encrypt_message(data, sessions = [], sk: nil, pk: nil) ⇒ Object
rubocop:disable Naming/MethodParameterName.
- #encrypted_audio(options) ⇒ Object
- #encrypted_contact(options) ⇒ Object
- #encrypted_data(options) ⇒ Object
- #encrypted_image(options) ⇒ Object
- #encrypted_message_request(message, sessions) ⇒ Object
-
#encrypted_message_results(messages, responses) ⇒ Object
Maps each pending message to its server response.
- #encrypted_post(options) ⇒ Object
- #encrypted_sticker(options) ⇒ Object
- #encrypted_text(options) ⇒ Object
- #encrypted_video(options) ⇒ Object
-
#fetch_encrypted_message_sessions!(recipient_ids, store, **kwargs) ⇒ Object
Fetch sessions for the given recipients and write them to the store.
-
#post_encrypted_messages(messages = nil, session_store: nil, **kwargs) ⇒ MixinBot::Models::ApiEnvelope
Encrypt and send messages using each recipient's current sessions.
- #send_encrypted_audio_message(options) ⇒ Object
- #send_encrypted_contact_message(options) ⇒ Object
- #send_encrypted_data_message(options) ⇒ Object
- #send_encrypted_image_message(options) ⇒ Object
-
#send_encrypted_message(payload) ⇒ Object
http post request.
- #send_encrypted_messages(messages) ⇒ Object
- #send_encrypted_post_message(options) ⇒ Object
- #send_encrypted_sticker_message(options) ⇒ Object
-
#send_encrypted_text_message(options) ⇒ Object
use HTTP to send message.
- #send_encrypted_video_message(options) ⇒ Object
Instance Method Details
#base_encrypted_message_params(options) ⇒ Object
base format of message params
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 80 def () data = [:data].is_a?(String) ? [:data] : [:data].to_json data_base64 = Base64.urlsafe_encode64(data, padding: false), [:sessions] session_ids = [:sessions].map { |s| s['session_id'] || s[:session_id] }.compact.sort checksum = MixinBot.utils.generate_user_checksum([:sessions]) { conversation_id: [:conversation_id], recipient_id: [:recipient_id], representative_id: [:representative_id], category: [:category], quote_message_id: [:quote_message_id], message_id: [:message_id] || SecureRandom.uuid, data_base64:, checksum:, recipient_sessions: session_ids.map { |session_id| { session_id: } }, # Go's MessageRequest always serializes `silent`; default stays false silent: ([:silent] ? true : false) }.compact end |
#cached_encrypted_sessions(store, recipient_id) ⇒ Object
Read a recipient's sessions from the store. Stores that raise (a Redis
outage) or return non-list values count as a cache miss, mirroring the
Go SDK's if sessions, err := store.Get(...); err == nil tolerance.
189 190 191 192 193 194 195 196 197 198 199 200 201 202 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 189 def cached_encrypted_sessions(store, recipient_id) cached = begin # block form keeps 1-arity duck-typed stores (Hash included) working store.fetch(recipient_id) { nil } # rubocop:disable Style/RedundantFetchBlock rescue StandardError nil end sessions = Array(cached) return nil unless sessions.all?(Hash) return nil if sessions.empty? sessions.map(&:stringify_keys) end |
#decrypt_message(data, sk: nil, si: nil) ⇒ Object
rubocop:disable Naming/MethodParameterName
293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 293 def (data, sk: nil, si: nil) # rubocop:disable Naming/MethodParameterName bytes = Base64.urlsafe_decode64(data).bytes si ||= config.session_id sk ||= config.session_private_key[0...32] size = 16 + 48 return '' if bytes.size < 1 + 2 + 32 + size + 12 session_length = bytes[1...3].pack('v*').unpack1('C*') prefix_size = 35 + (session_length * size) i = 35 key = '' while i < prefix_size uuid = MixinBot::UUID.new(raw: bytes[i...(i + 16)].pack('C*')).unpacked if uuid == si pub = bytes[3...35] aes_key = JOSE::JWA::X25519.shared_secret( pub.pack('C*'), JOSE::JWA::Ed25519.secret_to_curve25519(sk) ) iv = bytes[(i + 16)...(i + 16 + 16)].pack('C*') encrypted_key = bytes[(i + 16 + 16)...(i + size)].pack('C*') decrypter = OpenSSL::Cipher.new('AES-256-CBC').decrypt decrypter.iv = iv decrypter.key = aes_key cipher = decrypter.update(encrypted_key) key = cipher[...16] break end i += size end return '' unless key.size == 16 decrypter = OpenSSL::Cipher.new('AES-128-GCM').decrypt decrypter.key = key decrypter.iv = bytes[prefix_size...(prefix_size + 12)].pack('C*') decrypter.auth_tag = bytes.last(16).pack('C*') decrypted = decrypter.update(bytes[(prefix_size + 12)...-16].pack('C*')) decrypter.final Base64.urlsafe_encode64 decrypted end |
#encrypt_message(data, sessions = [], sk: nil, pk: nil) ⇒ Object
rubocop:disable Naming/MethodParameterName
251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 251 def (data, sessions = [], sk: nil, pk: nil) # rubocop:disable Naming/MethodParameterName raise ArgumentError, 'Wrong sessions format!' unless sessions.all?(&->(s) { s.key?('session_id') && s.key?('public_key') }) sk ||= config.session_private_key[0...32] pk ||= config.session_private_key[32...] encrypter = OpenSSL::Cipher.new('AES-128-GCM').encrypt key = encrypter.random_key nounce = encrypter.random_iv encrypter.key = key encrypter.iv = nounce encrypter.auth_data = '' ciphertext = encrypter.update(Base64.urlsafe_decode64(data)) + encrypter.final + encrypter.auth_tag bytes = [1] bytes.concat([sessions.size].pack('v*').bytes) bytes.concat(JOSE::JWA::Ed25519.pk_to_curve25519(pk).bytes) sessions.each do |session| aes_key = JOSE::JWA::X25519.shared_secret( Base64.urlsafe_decode64(session['public_key']), JOSE::JWA::Ed25519.secret_to_curve25519(sk) ) padding = 16 - (key.size % 16) padtext = ([padding] * padding).pack('C*') encrypter = OpenSSL::Cipher.new('AES-256-CBC').encrypt encrypter.key = aes_key iv = encrypter.random_iv encrypter.iv = iv bytes.concat((MixinBot::UUID.new(hex: session['session_id']).packed + iv).bytes) bytes.concat(encrypter.update(key + padtext).bytes) end bytes.concat(nounce.bytes) bytes.concat(ciphertext.bytes) Base64.urlsafe_encode64 bytes.pack('C*'), padding: false end |
#encrypted_audio(options) ⇒ Object
36 37 38 39 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 36 def encrypted_audio() .merge!(category: 'ENCRYPTED_AUDIO') () end |
#encrypted_contact(options) ⇒ Object
31 32 33 34 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 31 def encrypted_contact() .merge!(category: 'ENCRYPTED_CONTACT') () end |
#encrypted_data(options) ⇒ Object
21 22 23 24 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 21 def encrypted_data() .merge!(category: 'ENCRYPTED_DATA') () end |
#encrypted_image(options) ⇒ Object
16 17 18 19 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 16 def encrypted_image() .merge!(category: 'ENCRYPTED_IMAGE') () end |
#encrypted_message_request(message, sessions) ⇒ Object
226 227 228 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 226 def (, sessions) .merge(sessions:, silent: ([:silent] ? true : false)) end |
#encrypted_message_results(messages, responses) ⇒ Object
Maps each pending message to its server response. Returns [results_by_message_id, failed_messages]; raises on malformed responses.
232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 232 def (, responses) = responses.to_h { |response| [response['message_id'], response] } results = {} failures = [] .each do || response = [[:message_id]] raise ArgumentError, "encrypted message response missing for #{[:message_id]}" if response.nil? results[[:message_id]] = response case response['state'] when 'SUCCESS' then nil when 'FAILED' then failures << else raise ArgumentError, "encrypted message #{[:message_id]} returned unknown state #{response['state']}" end end [results, failures] end |
#encrypted_post(options) ⇒ Object
11 12 13 14 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 11 def encrypted_post() .merge!(category: 'ENCRYPTED_POST') () end |
#encrypted_sticker(options) ⇒ Object
26 27 28 29 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 26 def encrypted_sticker() .merge!(category: 'ENCRYPTED_STICKER') () end |
#encrypted_text(options) ⇒ Object
6 7 8 9 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 6 def encrypted_text() .merge!(category: 'ENCRYPTED_TEXT') () end |
#encrypted_video(options) ⇒ Object
41 42 43 44 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 41 def encrypted_video() .merge!(category: 'ENCRYPTED_VIDEO') () end |
#fetch_encrypted_message_sessions!(recipient_ids, store, **kwargs) ⇒ Object
Fetch sessions for the given recipients and write them to the store. Returns { recipient_id => sessions } for the resolved recipients.
206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 206 def (recipient_ids, store, **kwargs) return {} if recipient_ids.empty? response = fetch_user_sessions(recipient_ids, **kwargs.slice(:access_token, :exp_in, :scp)) grouped = {} Array(response['data']).each do |session| owner = session['user_id'].presence owner = recipient_ids.first if owner.blank? && recipient_ids.one? grouped[owner] = (grouped[owner] || []) + [session] if owner.present? end recipient_ids.each_with_object({}) do |recipient_id, resolved| sessions = grouped[recipient_id] raise ArgumentError, "no sessions found for recipient #{recipient_id}" if sessions.blank? store.store recipient_id, sessions.map(&:stringify_keys) resolved[recipient_id] = sessions.map(&:stringify_keys) end end |
#post_encrypted_messages(messages = nil, session_store: nil, **kwargs) ⇒ MixinBot::Models::ApiEnvelope
Encrypt and send messages using each recipient's current sessions.
Accepts a single options hash or an array of them (same shapes as
send_encrypted_*_message). Sessions are resolved through the session
store - session_store:, or the per-API-instance default store - and
fetched via POST /sessions/fetch on a cache miss. Messages the server
rejects because a recipient's sessions changed have their sessions
evicted, refreshed, and are retried once. The returned envelope carries
every message's final state (SUCCESS/FAILED).
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 129 def ( = nil, session_store: nil, **kwargs) = kwargs.delete(:messages) if .nil? && kwargs.key?(:messages) = [] unless .is_a? Array raise ArgumentError, 'messages must not be empty' if .empty? token_keys = %i[access_token exp_in scp] unknown = kwargs.keys - token_keys raise ArgumentError, "unsupported options: #{unknown.join(', ')}" if unknown.any? original = .map { || .to_h.transform_keys(&:to_sym) } seen_ids = [] pending = original.map do || raise ArgumentError, 'recipient_id is required' if [:recipient_id].blank? if [:message_id] && seen_ids.include?([:message_id]) raise ArgumentError, "duplicate message_id #{[:message_id]}" end [:message_id] ||= SecureRandom.uuid seen_ids << [:message_id] end store = session_store || (@session_store ||= MixinBot::SessionStore.new) final_results = {} responses = nil 2.times do |attempt| recipient_ids = pending.map { || [:recipient_id] }.uniq missing = recipient_ids.reject { |recipient_id| cached_encrypted_sessions(store, recipient_id).present? } fetched = missing.any? ? (missing, store, **kwargs) : {} requests = pending.map do || sessions = cached_encrypted_sessions(store, [:recipient_id]) || fetched[[:recipient_id]] raise ArgumentError, "no sessions found for recipient #{[:recipient_id]}" if sessions.blank? (, sessions) end responses = client.post '/encrypted_messages', *requests, **kwargs data = responses['data'] data = [data] if data.is_a?(Hash) raise ArgumentError, 'unexpected /encrypted_messages response format' unless data.is_a? Array results, failures = (pending, data) final_results.merge!(results) break if failures.empty? || attempt.positive? pending = failures # the FAILED recipients' sessions are assumed expired: evict + refresh pending.map { || [:recipient_id] }.uniq.each { |recipient_id| store.store recipient_id, nil } end responses.to_h['data'] = original.filter_map { || final_results[[:message_id]] } responses end |
#send_encrypted_audio_message(options) ⇒ Object
71 72 73 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 71 def () encrypted_audio() end |
#send_encrypted_contact_message(options) ⇒ Object
67 68 69 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 67 def () encrypted_contact() end |
#send_encrypted_data_message(options) ⇒ Object
59 60 61 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 59 def () encrypted_data() end |
#send_encrypted_image_message(options) ⇒ Object
55 56 57 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 55 def () encrypted_image() end |
#send_encrypted_message(payload) ⇒ Object
http post request
106 107 108 109 110 111 112 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 106 def (payload) path = '/encrypted_messages' payload = [payload] if payload.is_a? Hash raise ArgumentError, 'Wrong payload format!' unless payload.is_a? Array client.post path, *payload end |
#send_encrypted_messages(messages) ⇒ Object
101 102 103 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 101 def () end |
#send_encrypted_post_message(options) ⇒ Object
51 52 53 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 51 def () encrypted_post() end |
#send_encrypted_sticker_message(options) ⇒ Object
63 64 65 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 63 def () encrypted_sticker() end |
#send_encrypted_text_message(options) ⇒ Object
use HTTP to send message
47 48 49 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 47 def () encrypted_text() end |
#send_encrypted_video_message(options) ⇒ Object
75 76 77 |
# File 'lib/mixin_bot/api/encrypted_message.rb', line 75 def () encrypted_video() end |