diff --git a/.pubnub.yml b/.pubnub.yml index 331a77e10..cf458d9b1 100644 --- a/.pubnub.yml +++ b/.pubnub.yml @@ -1,6 +1,13 @@ --- -version: "5.2.2" +version: "5.3.0" changelog: + - date: 2023-10-16 + version: v5.3.0 + changes: + - type: feature + text: "Add crypto module that allows to configure SDK to encrypt and decrypt messages." + - type: bug + text: "Improved security of crypto implementation by adding enhanced AES-CBC cryptor." - date: 2023-03-14 version: v5.2.2 changes: @@ -663,7 +670,7 @@ sdks: - x86-64 - distribution-type: package distribution-repository: RubyGems - package-name: pubnub-5.2.2.gem + package-name: pubnub-5.3.0.gem location: https://rubygems.org/gems/pubnub requires: - name: addressable @@ -768,8 +775,8 @@ sdks: - x86-64 - distribution-type: library distribution-repository: GitHub release - package-name: pubnub-5.2.2.gem - location: https://github.com/pubnub/ruby/releases/download/v5.2.2/pubnub-5.2.2.gem + package-name: pubnub-5.3.0.gem + location: https://github.com/pubnub/ruby/releases/download/v5.3.0/pubnub-5.3.0.gem requires: - name: addressable min-version: 2.0.0 diff --git a/.tool-versions b/.tool-versions index 75d16c6f0..f2a971aa7 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -ruby jruby-9.3.8.0 +ruby 3.2.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index f4939b6f1..d45a5f06b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## v5.3.0 +October 16 2023 + +#### Added +- Add crypto module that allows to configure SDK to encrypt and decrypt messages. + +#### Fixed +- Improved security of crypto implementation by adding enhanced AES-CBC cryptor. + ## v5.2.2 March 14 2023 diff --git a/Gemfile b/Gemfile index 76fe82685..a14432579 100644 --- a/Gemfile +++ b/Gemfile @@ -17,7 +17,7 @@ end group :development, :test do gem 'awesome_print' - gem 'pry' + gem 'pry', '>= 0.14.2' gem 'pry-rescue' gem 'pry-stack_explorer' end diff --git a/Gemfile.lock b/Gemfile.lock index a25e36d6a..18a2bc9e8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - pubnub (5.2.2) + pubnub (5.3.0) addressable (>= 2.0.0) concurrent-ruby (~> 1.1.5) concurrent-ruby-edge (~> 0.5.0) @@ -100,8 +100,8 @@ GEM dry-equalizer (~> 0.2) dry-initializer (~> 3.0) dry-schema (~> 1.5) - ffi (1.13.1) - ffi (1.13.1-java) + ffi (1.16.2) + ffi (1.16.2-java) hashdiff (1.0.1) httpclient (2.8.3) interception (0.5) @@ -115,10 +115,10 @@ GEM parallel (1.19.2) parser (2.7.1.4) ast (~> 2.4.1) - pry (0.13.1) + pry (0.14.2) coderay (~> 1.1) method_source (~> 1.0) - pry (0.13.1-java) + pry (0.14.2-java) coderay (~> 1.1) method_source (~> 1.0) spoon (~> 0.0) @@ -187,7 +187,7 @@ DEPENDENCIES awesome_print codacy-coverage cucumber - pry + pry (>= 0.14.2) pry-rescue pry-stack_explorer pubnub! diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..5e1ef1880 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +PubNub Software Development Kit License Agreement +Copyright © 2023 PubNub Inc. All rights reserved. + +Subject to the terms and conditions of the license, you are hereby granted +a non-exclusive, worldwide, royalty-free license to (a) copy and modify +the software in source code or binary form for use with the software services +and interfaces provided by PubNub, and (b) redistribute unmodified copies +of the software to third parties. The software may not be incorporated in +or used to provide any product or service competitive with the products +and services of PubNub. + +The above copyright notice and this license shall be included +in or with all copies or substantial portions of the software. + +This license does not grant you permission to use the trade names, trademarks, +service marks, or product names of PubNub, except as required for reasonable +and customary use in describing the origin of the software and reproducing +the content of this license. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL PUBNUB OR THE AUTHORS OR COPYRIGHT HOLDERS OF THE SOFTWARE BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +https://www.pubnub.com/ +https://www.pubnub.com/terms diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index da5ecef98..000000000 --- a/LICENSE.txt +++ /dev/null @@ -1,30 +0,0 @@ -PubNub Real-time Cloud-Hosted Push API and Push Notification Client Frameworks -Copyright (c) 2013-2014 PubNub Inc. -http://www.pubnub.com/ -http://www.pubnub.com/terms - -MIT License - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -PubNub Real-time Cloud-Hosted Push API and Push Notification Client Frameworks -Copyright (c) 2013-2014 PubNub Inc. -http://www.pubnub.com/ -http://www.pubnub.com/terms \ No newline at end of file diff --git a/VERSION b/VERSION index ce7f2b425..03f488b07 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.2.2 +5.3.0 diff --git a/features/step_definitions/access_steps.rb b/features/step_definitions/access_steps.rb index eaa6184fc..2f65efb02 100644 --- a/features/step_definitions/access_steps.rb +++ b/features/step_definitions/access_steps.rb @@ -254,5 +254,3 @@ Then('the error detail message is not empty') do expect(parse_error_body(@global_state[:last_call_res])["error"]["message"].empty?).to eq false end - - diff --git a/features/step_definitions/crypto_steps.rb b/features/step_definitions/crypto_steps.rb new file mode 100644 index 000000000..e34c166eb --- /dev/null +++ b/features/step_definitions/crypto_steps.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true +# +require 'pubnub' + +Given(/^Crypto module with '([^']*)' cryptor$/) do |cryptor_id| + @cryptor_ids = [cryptor_id] +end + +Given(/^Crypto module with default '([^']*)' and additional '([^']*)' cryptors$/) do |cryptor_id1, cryptor_id2| + @cryptor_ids = [cryptor_id1, cryptor_id2] +end + +Given(/^Legacy code with '([^']*)' cipher key and '(random|constant|-)' vector$/) do |cipher_key, use_random_iv| + use_random_iv = use_random_iv != 'constant' + @legacy_cryptor = Cryptor.new cipher_key, use_random_iv +end + +Then(/^with '([^']*)' cipher key$/) do |cipher_key| + @cipher_key = cipher_key +end + +Then(/^with '(random|constant|-)' vector$/) do |use_random_iv| + @use_random_iv = use_random_iv != 'constant' +end + +When(/^I encrypt '([^']*)' file as '([^']*)'$/) do |file_name, _| + @source_file_name = file_name + @source_file_content = File.binread "sdk-specifications/features/encryption/assets/#{file_name}" + @encrypted_content = crypto_module.encrypt @source_file_content + if file_name.include? 'empty' + @encrypt_status = 'encryption error' if @encrypted_content.nil? && @encrypt_status.nil? + @encrypt_status = 'success' if !@encrypted_content.nil? && @encrypt_status.nil? + else + expect(@encrypted_content).not_to eq nil + end +end + +When(/^I decrypt '([^']*)' file$/) do |file_name| + file_content = File.binread "sdk-specifications/features/encryption/assets/#{file_name}" + + begin + @decrypted_content = crypto_module.decrypt file_content + rescue Pubnub::UnknownCryptorError + @decrypt_status = 'unknown cryptor error' + end + @decrypt_status = 'decryption error' if @decrypted_content.nil? && @decrypt_status.nil? + @decrypt_status = 'success' if !@decrypted_content.nil? && @decrypt_status.nil? +end + +When(/^I decrypt '([^']*)' file as '([^']*)'$/) do |file_name, _| + file_content = File.binread "sdk-specifications/features/encryption/assets/#{file_name}" + + begin + @decrypted_content = crypto_module.decrypt file_content + rescue Pubnub::UnknownCryptorError + @decrypt_status = 'unknown cryptor error' + end + @decrypt_status = 'decryption error' if @decrypted_content.nil? && @decrypt_status.nil? + @decrypt_status = 'success' if !@decrypted_content.nil? && @decrypt_status.nil? +end + +Then(/^Decrypted file content equal to the '([^']*)' file content$/) do |file_name| + file_content = File.binread "sdk-specifications/features/encryption/assets/#{file_name}" + expect(@decrypted_content).not_to eq nil + expect(@decrypted_content).to eq file_content +end + +Then('Successfully decrypt an encrypted file with legacy code') do + expect(@legacy_cryptor).not_to eq nil + base64_encoded = Base64.strict_encode64(@encrypted_content) + decrypted_content = @legacy_cryptor.decrypt(base64_encoded) + expect(decrypted_content).not_to eq nil + expect(decrypted_content).to eq @source_file_content +end + +Then(/^I receive '([^']*)'$/) do |outcome| + expect(@encrypt_status || @decrypt_status).not_to eq nil + expect(@encrypt_status || @decrypt_status).to eq outcome +end + +# Crypto module +# +# @return [Pubnub::Crypto::CryptoModule] Crypto module instance. +def crypto_module + cryptors = [] + @cryptor_ids.each do |cryptor_id| + cryptor = if cryptor_id == 'acrh' + Pubnub::Crypto::AesCbcCryptor.new @cipher_key + elsif cryptor_id == 'legacy' + Pubnub::Crypto::LegacyCryptor.new @cipher_key, @use_random_iv + end + cryptors.push(cryptor) unless cryptor.nil? + end + + raise ArgumentError, "No crypto identifiers specified: #{@cryptor_ids}" if cryptors.empty? + + default_cryptor = cryptors.shift + Pubnub::Crypto::CryptoModule.new default_cryptor, cryptors unless default_cryptor.nil? +end diff --git a/features/support/cryptor.rb b/features/support/cryptor.rb new file mode 100644 index 000000000..091be4d79 --- /dev/null +++ b/features/support/cryptor.rb @@ -0,0 +1,58 @@ +# Internal Crypto class used for message encryption and decryption +class Cryptor + def initialize(cipher_key, use_random_iv) + @alg = 'AES-256-CBC' + sha256_key = Digest::SHA256.hexdigest(cipher_key.to_s) + @key = sha256_key.slice(0, 32) + @using_random_iv = use_random_iv + @iv = @using_random_iv == true ? random_iv : '0123456789012345' + end + + def encrypt(message) + aes = OpenSSL::Cipher.new(@alg) + aes.encrypt + aes.key = @key + aes.iv = @iv + + json_message = message.to_json + cipher = @using_random_iv == true ? @iv : '' + cipher << aes.update(json_message) + cipher << aes.final + + Base64.strict_encode64(cipher) + end + + def decrypt(cipher_text) + undecoded_text = Base64.decode64(cipher_text) + iv = @iv + + if cipher_text.length > 16 && @using_random_iv == true + iv = undecoded_text.slice!(0..15) + end + + decode_cipher = OpenSSL::Cipher.new(@alg).decrypt + decode_cipher.key = @key + decode_cipher.iv = iv + + plain_text = decryption(undecoded_text, decode_cipher) + + plain_text + end + + private + + def decryption(cipher_text, decode_cipher) + plain_text = decode_cipher.update(cipher_text) + plain_text << decode_cipher.final + rescue StandardError => e + puts "Pubnub :: DECRYPTION ERROR: #{e}" + '"DECRYPTION ERROR"' + end + + private + + def random_iv + random_bytes = Random.new.bytes(16).unpack('NnnnnN') + format('%08x%04x%04x', *random_bytes) + end +end \ No newline at end of file diff --git a/features/support/hooks.rb b/features/support/hooks.rb index b27fc57af..876d9a379 100644 --- a/features/support/hooks.rb +++ b/features/support/hooks.rb @@ -8,7 +8,6 @@ @pn_configuration = {} when_mock_server_used { - puts "Using mock" expect(ENV['SERVER_HOST']).not_to be_nil expect(ENV['SERVER_PORT']).not_to be_nil @pn_configuration = { diff --git a/lib/pubnub/client.rb b/lib/pubnub/client.rb index 0614ef0e3..e11c814b8 100644 --- a/lib/pubnub/client.rb +++ b/lib/pubnub/client.rb @@ -1,13 +1,16 @@ +# frozen_string_literal: true + require 'base64' require 'pubnub/error' require 'pubnub/uuid' require 'pubnub/formatter' -require 'pubnub/crypto' require 'pubnub/constants' require 'pubnub/configuration' require 'pubnub/subscribe_callback' +require 'pubnub/modules/crypto/module' + require 'pubnub/schemas/envelope_schema' require 'pubnub/event' @@ -183,6 +186,7 @@ def initialize(options) clean_env prepare_env validate! @env + setup_crypto_module @telemetry = Telemetry.new Pubnub.logger.debug('Pubnub::Client') do "Created new Pubnub::Client instance. Version: #{Pubnub::VERSION}" @@ -339,6 +343,14 @@ def set_token(token) @env[:token] = token end + # Data processing crypto module. + # + # @return [Pubnub::Crypto::CryptoProvider, nil] Crypto module for data encryption and + # decryption. + def crypto_module + @env[:crypto_module] + end + private def create_state_pools(event) @@ -386,9 +398,26 @@ def setup_app(options) Concurrent.global_logger = Pubnub.logger @subscriber = Subscriber.new(self) options[:user_id] = options[:uuid] if options[:user_id].nil? + + if options[:cipher_key] && options[:crypto_module] + puts 'It is expected that only cipherKey or cryptoModule will be configured ' \ + 'at once. PubNub client will use the configured cryptoModule.' + end + @env = options end + # Complete crypto module configuration + # Create crypto module if it is required by user (specified + # cipher_key and not crypto_module). + def setup_crypto_module + random_iv = @env[:random_iv] + key = @env[:cipher_key] + + # Create crypto module if it is not specified + @env[:crypto_module] = Crypto::CryptoModule.new_legacy(key, random_iv) if key && @env[:crypto_module].nil? + end + def prepare_env assign_defaults setup_pools diff --git a/lib/pubnub/crypto.rb b/lib/pubnub/crypto.rb deleted file mode 100644 index 6402b97d8..000000000 --- a/lib/pubnub/crypto.rb +++ /dev/null @@ -1,70 +0,0 @@ -# Toplevel Pubnub module. -module Pubnub - # Internal Crypto class used for message encryption and decryption - class Crypto - def initialize(cipher_key, use_random_iv) - @alg = 'AES-256-CBC' - sha256_key = Digest::SHA256.hexdigest(cipher_key.to_s) - @key = sha256_key.slice(0, 32) - @using_random_iv = use_random_iv - @iv = @using_random_iv == true ? random_iv : '0123456789012345' - end - - def encrypt(message) - aes = OpenSSL::Cipher.new(@alg) - aes.encrypt - aes.key = @key - aes.iv = @iv - - json_message = message.to_json - cipher = @using_random_iv == true ? @iv : '' - cipher << aes.update(json_message) - cipher << aes.final - - Base64.strict_encode64(cipher) - end - - def decrypt(cipher_text) - undecoded_text = Base64.decode64(cipher_text) - iv = @iv - - if cipher_text.length > 16 && @using_random_iv == true - iv = undecoded_text.slice!(0..15) - end - - decode_cipher = OpenSSL::Cipher.new(@alg).decrypt - decode_cipher.key = @key - decode_cipher.iv = iv - - plain_text = decryption(undecoded_text, decode_cipher) - load_json(plain_text) - - Pubnub.logger.debug('Pubnub') { 'Finished decrypting' } - - plain_text - end - - private - - def decryption(cipher_text, decode_cipher) - plain_text = decode_cipher.update(cipher_text) - plain_text << decode_cipher.final - rescue StandardError => e - Pubnub.error('Pubnub') { "DECRYPTION ERROR #{e}" } - '"DECRYPTION ERROR"' - end - - def load_json(plain_text) - JSON.load(plain_text) - rescue JSON::ParserError - JSON.load("[#{plain_text}]")[0] - end - - private - - def random_iv - random_bytes = Random.new.bytes(16).unpack('NnnnnN') - format('%08x%04x%04x', *random_bytes) - end - end -end diff --git a/lib/pubnub/error.rb b/lib/pubnub/error.rb index 7d3510aff..19f2ec7ba 100644 --- a/lib/pubnub/error.rb +++ b/lib/pubnub/error.rb @@ -51,4 +51,7 @@ class RequestError < Error class ResponseError < Error end + + class UnknownCryptorError < Error + end end diff --git a/lib/pubnub/event.rb b/lib/pubnub/event.rb index 640cbb45a..42c0f5206 100644 --- a/lib/pubnub/event.rb +++ b/lib/pubnub/event.rb @@ -113,12 +113,12 @@ def enable_format_group? def operation_http_method case @event when Pubnub::Constants::OPERATION_DELETE, Pubnub::Constants::OPERATION_REMOVE_MESSAGE_ACTION, - Pubnub::Constants::OPERATION_REMOVE_CHANNEL_METADATA, Pubnub::Constants::OPERATION_REMOVE_UUID_METADATA, - Pubnub::Constants::OPERATION_REVOKE_TOKEN + Pubnub::Constants::OPERATION_REMOVE_CHANNEL_METADATA, Pubnub::Constants::OPERATION_REMOVE_UUID_METADATA, + Pubnub::Constants::OPERATION_REVOKE_TOKEN 'delete' when Pubnub::Constants::OPERATION_SET_UUID_METADATA, Pubnub::Constants::OPERATION_SET_CHANNEL_METADATA, - Pubnub::Constants::OPERATION_SET_CHANNEL_MEMBERS, Pubnub::Constants::OPERATION_SET_MEMBERSHIPS, - Pubnub::Constants::OPERATION_REMOVE_CHANNEL_MEMBERS, Pubnub::Constants::OPERATION_REMOVE_MEMBERSHIPS + Pubnub::Constants::OPERATION_SET_CHANNEL_MEMBERS, Pubnub::Constants::OPERATION_SET_MEMBERSHIPS, + Pubnub::Constants::OPERATION_REMOVE_CHANNEL_MEMBERS, Pubnub::Constants::OPERATION_REMOVE_MEMBERSHIPS 'patch' when Pubnub::Constants::OPERATION_ADD_MESSAGE_ACTION 'post' @@ -170,7 +170,7 @@ def handle(response, request) def create_variables_from_options(options) variables = %w[channel channels message http_sync callback - ssl cipher_key random_iv secret_key auth_key + ssl cipher_key random_iv crypto_module secret_key auth_key publish_key subscribe_key timetoken action_timetoken message_timetoken open_timeout read_timeout idle_timeout heartbeat group action read write delete manage ttl presence start @@ -217,6 +217,14 @@ def compute_random_iv(data) ck.call(data) end + # Data processing crypto module. + # + # @return [Pubnub::Crypto::CryptoProvider, nil] Crypto module for data encryption and + # decryption. + def crypto_module + @crypto_module + end + def error_message(parsed_response) parsed_response['message'] rescue StandardError diff --git a/lib/pubnub/events/add_message_action.rb b/lib/pubnub/events/add_message_action.rb index 874811429..176452859 100644 --- a/lib/pubnub/events/add_message_action.rb +++ b/lib/pubnub/events/add_message_action.rb @@ -13,8 +13,8 @@ def initialize(options, app) def fire Pubnub.logger.debug('Pubnub::Add Message Action') { "Fired event #{self.class}" } - type_payload = { type: @type, value: @value} - body = Formatter.format_message(type_payload, '', false, false) + type_payload = { type: @type, value: @value } + body = Formatter.format_message(type_payload, nil, false) response = send_request(body, { "Content-Type": 'application/json' }) diff --git a/lib/pubnub/events/grant_token.rb b/lib/pubnub/events/grant_token.rb index dc474eb1c..611defa4f 100644 --- a/lib/pubnub/events/grant_token.rb +++ b/lib/pubnub/events/grant_token.rb @@ -34,7 +34,7 @@ def fire patterns: prepare_permissions(:pattern, @channels, @channel_groups, @uuids, @spaces_permissions, @users_permissions) }.select { |_, v| v } } - body = Formatter.format_message(raw_body, "", false, false) + body = Formatter.format_message(raw_body, nil, false) response = send_request(body, { "Content-Type": "application/json" }) envelopes = fire_callbacks(handle(response, uri)) diff --git a/lib/pubnub/events/history.rb b/lib/pubnub/events/history.rb index 2637f43e8..5961080dc 100644 --- a/lib/pubnub/events/history.rb +++ b/lib/pubnub/events/history.rb @@ -8,6 +8,11 @@ class History < SingleEvent def initialize(options, app) @event = :history @telemetry_name = :l_hist + + # Override crypto module if custom cipher key has been used. + random_iv = options.key?(:random_iv) ? options[:random_iv] : true + options[:crypto_module] = Crypto::CryptoModule.new_legacy(options[:cipher_key], random_iv) if options[:cipher_key] + super end @@ -63,23 +68,30 @@ def parameters(*_args) def decrypt_history(message, crypto) if @include_token || @include_meta - message['message'] = JSON.parse(crypto.decrypt(message['message']), quirks_mode: true) + encrypted_message = Base64.decode64(message['message']) + message['message'] = JSON.parse(crypto.decrypt(encrypted_message), quirks_mode: true) message else - JSON.parse(crypto.decrypt(message), quirks_mode: true) + encrypted_message = Base64.decode64(message) + JSON.parse(crypto.decrypt(encrypted_message), quirks_mode: true) end end def valid_envelope(parsed_response, req_res_objects) messages = parsed_response[0] - if (@cipher_key || @app.env[:cipher_key] || @cipher_key_selector || @app.env[:cipher_key_selector]) && messages - cipher_key = compute_cipher_key(parsed_response) - random_iv = compute_random_iv(parsed_response) - crypto = Crypto.new(cipher_key, random_iv) + # TODO: Uncomment code below when cryptor implementations will be added. + if crypto_module && messages + crypto = crypto_module messages = messages.map { |message| decrypt_history(message, crypto) } end + # if (@cipher_key || @app.env[:cipher_key] || @cipher_key_selector || @app.env[:cipher_key_selector]) && messages + # cipher_key = compute_cipher_key(parsed_response) + # random_iv = compute_random_iv(parsed_response) + # crypto = Crypto.new(cipher_key, random_iv) + # messages = messages.map { |message| decrypt_history(message, crypto) } + # end start = parsed_response[1] finish = parsed_response[2] diff --git a/lib/pubnub/events/publish.rb b/lib/pubnub/events/publish.rb index d32eb69f5..34c51ffd5 100644 --- a/lib/pubnub/events/publish.rb +++ b/lib/pubnub/events/publish.rb @@ -10,6 +10,11 @@ class Publish < SingleEvent def initialize(options, app) @event = :publish @telemetry_name = :l_pub + + # Override crypto module if custom cipher key has been used. + random_iv = options.key?(:random_iv) ? options[:random_iv] : true + options[:crypto_module] = Crypto::CryptoModule.new_legacy(options[:cipher_key], random_iv) if options[:cipher_key] + super @sequence_number = sequence_number! @origination_time_token = @app.generate_ortt @@ -25,9 +30,8 @@ def initialize(options, app) def fire Pubnub.logger.debug('Pubnub::Publish') { "Fired event #{self.class}" } - if @compressed - compressed_body = Formatter.format_message(@message, @cipher_key, @random_iv, false) + compressed_body = Formatter.format_message(@message, @crypto_module, false) response = send_request(compressed_body) else response = send_request @@ -72,7 +76,7 @@ def path '0', Formatter.format_channel(@channel, true), '0', - Formatter.format_message(@message, @cipher_key, @random_iv) + Formatter.format_message(@message, @crypto_module) ] rpath.pop if @compressed diff --git a/lib/pubnub/events/remove_channel_members.rb b/lib/pubnub/events/remove_channel_members.rb index 90165c130..3fdf4b285 100644 --- a/lib/pubnub/events/remove_channel_members.rb +++ b/lib/pubnub/events/remove_channel_members.rb @@ -42,7 +42,7 @@ def fire { uuid: { id: member } } end - body = Formatter.format_message({ delete: members }, "", @random_iv, false) + body = Formatter.format_message({ delete: members }, nil, false) response = send_request(body) envelopes = fire_callbacks(handle(response, uri)) @@ -83,11 +83,11 @@ def path def valid_envelope(parsed_response, req_res_objects) members = parsed_response['data'].map { |channel_member| member = Hash.new - channel_member.each{ |k,v| member[k.to_sym] = v } + channel_member.each { |k, v| member[k.to_sym] = v } unless member[:uuid].nil? uuid_metadata = Hash.new - member[:uuid].each{ |k,v| uuid_metadata[k.to_sym] = v } + member[:uuid].each { |k, v| uuid_metadata[k.to_sym] = v } uuid_metadata[:updated] = Date._parse(uuid_metadata[:updated]) unless uuid_metadata[:updated].nil? member[:uuid] = uuid_metadata end diff --git a/lib/pubnub/events/remove_channel_metadata.rb b/lib/pubnub/events/remove_channel_metadata.rb index 9f05286b0..76ea84319 100644 --- a/lib/pubnub/events/remove_channel_metadata.rb +++ b/lib/pubnub/events/remove_channel_metadata.rb @@ -17,7 +17,7 @@ def initialize(options, app) def fire Pubnub.logger.debug('Pubnub::RemoveChannelMetadata') { "Fired event #{self.class}" } - body = Formatter.format_message(@data, "", @random_iv, false) + body = Formatter.format_message(@data, nil, false) response = send_request(body) envelopes = fire_callbacks(handle(response, uri)) diff --git a/lib/pubnub/events/remove_memberships.rb b/lib/pubnub/events/remove_memberships.rb index 6e3ea08c7..1baeda46e 100644 --- a/lib/pubnub/events/remove_memberships.rb +++ b/lib/pubnub/events/remove_memberships.rb @@ -42,7 +42,7 @@ def fire { channel: { id: membership } } end - body = Formatter.format_message({ delete: memberships }, "", @random_iv, false) + body = Formatter.format_message({ delete: memberships }, nil, false) response = send_request(body) envelopes = fire_callbacks(handle(response, uri)) @@ -83,11 +83,11 @@ def path def valid_envelope(parsed_response, req_res_objects) memberships = parsed_response['data'].map { |uuid_membership| membership = Hash.new - uuid_membership.each{ |k,v| membership[k.to_sym] = v } + uuid_membership.each { |k, v| membership[k.to_sym] = v } unless membership[:channel].nil? channel_metadata = Hash.new - membership[:channel].each{ |k,v| channel_metadata[k.to_sym] = v } + membership[:channel].each { |k, v| channel_metadata[k.to_sym] = v } channel_metadata[:updated] = Date._parse(channel_metadata[:updated]) unless channel_metadata[:updated].nil? membership[:channel] = channel_metadata end diff --git a/lib/pubnub/events/remove_uuid_metadata.rb b/lib/pubnub/events/remove_uuid_metadata.rb index fe8a8aaff..efd6e2c89 100644 --- a/lib/pubnub/events/remove_uuid_metadata.rb +++ b/lib/pubnub/events/remove_uuid_metadata.rb @@ -17,7 +17,7 @@ def initialize(options, app) def fire Pubnub.logger.debug('Pubnub::RemoveUuidMetadata') { "Fired event #{self.class}" } - body = Formatter.format_message(@data, "", @random_iv, false) + body = Formatter.format_message(@data, nil, false) response = send_request(body) envelopes = fire_callbacks(handle(response, uri)) diff --git a/lib/pubnub/events/set_channel_members.rb b/lib/pubnub/events/set_channel_members.rb index c21b31a94..8db950152 100644 --- a/lib/pubnub/events/set_channel_members.rb +++ b/lib/pubnub/events/set_channel_members.rb @@ -45,7 +45,7 @@ def fire member_object end - body = Formatter.format_message({ set: members }, "", @random_iv, false) + body = Formatter.format_message({ set: members }, nil, false) response = send_request(body) envelopes = fire_callbacks(handle(response, uri)) @@ -86,11 +86,11 @@ def path def valid_envelope(parsed_response, req_res_objects) members = parsed_response['data'].map { |channel_member| member = Hash.new - channel_member.each{ |k,v| member[k.to_sym] = v } + channel_member.each { |k, v| member[k.to_sym] = v } unless member[:uuid].nil? uuid_metadata = Hash.new - member[:uuid].each{ |k,v| uuid_metadata[k.to_sym] = v } + member[:uuid].each { |k, v| uuid_metadata[k.to_sym] = v } uuid_metadata[:updated] = Date._parse(uuid_metadata[:updated]) unless uuid_metadata[:updated].nil? member[:uuid] = uuid_metadata end diff --git a/lib/pubnub/events/set_channel_metadata.rb b/lib/pubnub/events/set_channel_metadata.rb index 5f368b2d6..7e3aba6cf 100644 --- a/lib/pubnub/events/set_channel_metadata.rb +++ b/lib/pubnub/events/set_channel_metadata.rb @@ -27,7 +27,7 @@ def initialize(options, app) def fire Pubnub.logger.debug('Pubnub::SetChannelMetadata') { "Fired event #{self.class}" } - body = Formatter.format_message(@metadata, "", @random_iv, false) + body = Formatter.format_message(@metadata, nil, false) response = send_request(body) envelopes = fire_callbacks(handle(response, uri)) @@ -60,7 +60,7 @@ def path def valid_envelope(parsed_response, req_res_objects) data = parsed_response['data'] metadata = Hash.new - data.each{ |k,v| metadata[k.to_sym] = v } + data.each { |k, v| metadata[k.to_sym] = v } metadata[:updated] = Date._parse(metadata[:updated]) unless metadata[:updated].nil? Pubnub::Envelope.new( diff --git a/lib/pubnub/events/set_memberships.rb b/lib/pubnub/events/set_memberships.rb index 9a8a914f2..b582b4e34 100644 --- a/lib/pubnub/events/set_memberships.rb +++ b/lib/pubnub/events/set_memberships.rb @@ -45,7 +45,7 @@ def fire membership_object end - body = Formatter.format_message({ set: memberships }, "", @random_iv, false) + body = Formatter.format_message({ set: memberships }, nil, false) response = send_request(body) envelopes = fire_callbacks(handle(response, uri)) @@ -86,11 +86,11 @@ def path def valid_envelope(parsed_response, req_res_objects) memberships = parsed_response['data'].map { |uuid_membership| membership = Hash.new - uuid_membership.each{ |k,v| membership[k.to_sym] = v } + uuid_membership.each { |k, v| membership[k.to_sym] = v } unless membership[:channel].nil? channel_metadata = Hash.new - membership[:channel].each{ |k,v| channel_metadata[k.to_sym] = v } + membership[:channel].each { |k, v| channel_metadata[k.to_sym] = v } channel_metadata[:updated] = Date._parse(channel_metadata[:updated]) unless channel_metadata[:updated].nil? membership[:channel] = channel_metadata end diff --git a/lib/pubnub/events/set_uuid_metadata.rb b/lib/pubnub/events/set_uuid_metadata.rb index a79cd7f0a..cefa7cd57 100644 --- a/lib/pubnub/events/set_uuid_metadata.rb +++ b/lib/pubnub/events/set_uuid_metadata.rb @@ -28,7 +28,7 @@ def initialize(options, app) def fire Pubnub.logger.debug('Pubnub::SetUuidMetadata') { "Fired event #{self.class}" } - body = Formatter.format_message(@metadata, "", @random_iv, false) + body = Formatter.format_message(@metadata, nil, false) response = send_request(body) envelopes = fire_callbacks(handle(response, uri)) @@ -61,7 +61,7 @@ def path def valid_envelope(parsed_response, req_res_objects) data = parsed_response['data'] metadata = Hash.new - data.each{ |k,v| metadata[k.to_sym] = v } + data.each { |k, v| metadata[k.to_sym] = v } metadata[:updated] = Date._parse(metadata[:updated]) unless metadata[:updated].nil? Pubnub::Envelope.new( diff --git a/lib/pubnub/events/signal.rb b/lib/pubnub/events/signal.rb index 1c7d152c9..67c31c90d 100644 --- a/lib/pubnub/events/signal.rb +++ b/lib/pubnub/events/signal.rb @@ -39,7 +39,7 @@ def path '0', Formatter.format_channel(@channel, true), '0', - Formatter.format_message(@message, @cipher_key, @random_iv) + Formatter.format_message(@message, @crypto_module) ].join('/') end diff --git a/lib/pubnub/events/subscribe.rb b/lib/pubnub/events/subscribe.rb index 6a0ef6ad9..5348518e5 100644 --- a/lib/pubnub/events/subscribe.rb +++ b/lib/pubnub/events/subscribe.rb @@ -8,6 +8,11 @@ class Subscribe < SubscribeEvent def initialize(options, app) @event = :subscribe + + # Override crypto module if custom cipher key has been used. + random_iv = options.key?(:random_iv) ? options[:random_iv] : true + options[:crypto_module] = Crypto::CryptoModule.new_legacy(options[:cipher_key], random_iv) if options[:cipher_key] + super app.apply_state(self) end diff --git a/lib/pubnub/formatter.rb b/lib/pubnub/formatter.rb index 6c7d2f27b..bff735e4c 100644 --- a/lib/pubnub/formatter.rb +++ b/lib/pubnub/formatter.rb @@ -41,17 +41,28 @@ def format_uuid(uuids, should_encode = true) end end - # Transforms message to json and encode it - def format_message(message, cipher_key = "", use_random_iv = false, uri_escape = true) - if cipher_key && !cipher_key.empty? - pc = Pubnub::Crypto.new(cipher_key, use_random_iv) - message = pc.encrypt(message).to_json - message = Addressable::URI.escape(message) if uri_escape - else - message = message.to_json - message = Formatter.encode(message) if uri_escape + # TODO: Uncomment code below when cryptor implementations will be added. + # Transforms message to json and encode it. + # + # @param message [Hash, String, Integer, Boolean] Message data which + # should be formatted. + # @param crypto [Crypto::CryptoProvider, nil] Crypto which should be used to + # encrypt message data. + # @param uri_escape [Boolean, nil] Whether formatted message should escape + # to be used as part of URI or not. + # @return [String, nil] Formatted message data. + def format_message(message, crypto = nil, uri_escape = true) + json_message = message.to_json + if crypto + encrypted_data = crypto&.encrypt(json_message) + json_message = Base64.strict_encode64(encrypted_data).to_json unless encrypted_data.nil? + end + + if uri_escape + json_message = Formatter.encode(json_message) if crypto.nil? + json_message = Addressable::URI.escape(json_message).to_s unless crypto.nil? end - message + json_message end # Quite lazy way, but good enough for current usage @@ -100,7 +111,7 @@ def make_uuid_array(uuid) # Parses string to JSON def parse_json(string) [JSON.parse(string), nil] - rescue JSON::ParserError => _error + rescue JSON::ParserError => _e [nil, JSON::ParserError] end diff --git a/lib/pubnub/modules/crypto/crypto_module.rb b/lib/pubnub/modules/crypto/crypto_module.rb new file mode 100644 index 000000000..a65b32d27 --- /dev/null +++ b/lib/pubnub/modules/crypto/crypto_module.rb @@ -0,0 +1,159 @@ +module Pubnub + module Crypto + # Crypto module for data processing. + # + # The PubNub client uses a module to encrypt and decrypt sent data in a way + # that's compatible with previous versions (if additional cryptors have been + # registered). + class CryptoModule < CryptoProvider + # AES-CBC cryptor based module. + # + # Data encryption and decryption will be done by default + # using the AesCbcCryptor. In addition to the AesCbcCryptor + # for data decryption, the LegacyCryptor will be registered + # for backward-compatibility. + # + # @param cipher_key [String] Key for data encryption and decryption. + # @param use_random_iv [Boolean] Whether random IV should be used for data + # decryption. + # + # @raise [ArgumentError] If the cipher_key is missing or empty. + def self.new_aes_cbc(cipher_key, use_random_iv) + if cipher_key.nil? || cipher_key.empty? + raise ArgumentError, { + message: '\'cipher_key\' is missing or empty.' + } + end + + CryptoModule.new AesCbcCryptor.new(cipher_key), [LegacyCryptor.new(cipher_key, use_random_iv)] + end + + # Legacy AES-CBC cryptor based module. + # + # Data encryption and decryption will be done by default + # using the LegacyCrypto. In addition to the LegacyCrypto + # for data decryption, the AesCbcCryptor will be registered + # for future-compatibility (which will help with gradual application + # updates). + # + # @param cipher_key [String] Key for data encryption and decryption. + # @param use_random_iv [Boolean] Whether random IV should be used for data + # decryption. + # + # @raise [ArgumentError] If the cipher_key is missing or empty. + def self.new_legacy(cipher_key, use_random_iv) + if cipher_key.nil? || cipher_key.empty? + raise ArgumentError, { + message: '\'cipher_key\' is missing or empty.' + } + end + + CryptoModule.new LegacyCryptor.new(cipher_key, use_random_iv), [AesCbcCryptor.new(cipher_key)] + end + + # Create crypto module. + # + # @param default [Cryptor] Default cryptor used to encrypt and decrypt + # data. + # @param cryptors [Array, nil] Additional cryptors which will be + # used to decrypt data encrypted by previously used cryptors. + def initialize(default, cryptors) + if default.nil? + raise ArgumentError, { + message: '\'default\' cryptor required for data encryption.' + } + end + + @default = default + @cryptors = cryptors&.each_with_object({}) do |value, hash| + hash[value.identifier] = value + end || {} + super() + end + + def encrypt(data) + # Encrypting provided data. + encrypted_data = default_cryptor.encrypt(data) + return nil if encrypted_data.nil? + + payload = Crypto::CryptorHeader.new(default_cryptor.identifier, encrypted_data.metadata).to_s + payload << encrypted_data.metadata unless encrypted_data.metadata.nil? + payload << encrypted_data.data + end + + def decrypt(data) + if data.nil? || data.empty? + puts 'Pubnub :: DECRYPTION ERROR: Empty data for decryption' + return nil + end + + header = Crypto::CryptorHeader.parse(data) + return nil if header.nil? + + cryptor_identifier = header.identifier || '\x00\x00\x00\x00' + cryptor = cryptor cryptor_identifier + + # Check whether there is a cryptor to decrypt data or not. + if cryptor.nil? + identifier = header.identifier || 'UNKN' + raise UnknownCryptorError, { + message: "Decrypting data created by unknown cryptor. Please make sure to register +#{identifier} or update SDK." + } + end + + encrypted_data = data[header.length..-1] + metadata = metadata encrypted_data, header.data_size + + # Check whether there is still some data for processing or not. + return nil if encrypted_data.nil? || encrypted_data.empty? + + cryptor.decrypt(EncryptedData.new(encrypted_data, metadata)) + end + + private + + # Cryptor used by the module by default to encrypt data. + # + # @return [Cryptor] Default cryptor used to encrypt and decrypt data. + def default_cryptor + @default + end + + # Additional cryptors that can be used to decrypt data if the + # default_cryptor can't. + # + # @return [Hash] Map of Cryptor to their identifiers. + def additional_cryptors + @cryptors + end + + # Extract metadata information from source data. + # + # @param data [String, nil] Encrypted data from which cryptor metadata + # should be extracted. + # @param size [Integer] Size of cryptor-defined data. + # @return [String, nil] Extracted metadata or nil in case if + # size is 0. + def metadata(data, size) + return nil if !data || !size.positive? + + data&.slice!(0..(size - 1)) + end + + # Find cryptor with a specified identifier. + # + # Data decryption can only be done with registered cryptors. An identifier + # in the cryptor data header is used to identify a suitable cryptor. + # + # @param identifier [String] A unicode cryptor identifier. + # @return [Cryptor, nil] Target cryptor or `nil` in case there is none + # with the specified identifier. + def cryptor(identifier) + return default_cryptor if default_cryptor.identifier == identifier + + additional_cryptors.fetch(identifier, nil) + end + end + end +end \ No newline at end of file diff --git a/lib/pubnub/modules/crypto/crypto_provider.rb b/lib/pubnub/modules/crypto/crypto_provider.rb new file mode 100644 index 000000000..12d1f411e --- /dev/null +++ b/lib/pubnub/modules/crypto/crypto_provider.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Pubnub + module Crypto + # Base class which is used to implement a module that can be used to + # configure PubNub client or for manual data encryption and + # decryption. + class CryptoProvider + # Encrypt provided data. + # + # @param data [String] Source data for encryption. + # @return [String, nil] Encrypted data or nil in case of encryption + # error. + def encrypt(data) + raise NotImplementedError, 'Subclass should provide "encrypt" method implementation.' + end + + # Decrypt provided data. + # + # @param data [String] Encrypted data for decryption. + # @return [String, nil] Decrypted data or nil in case of decryption + # error. + # + # @raise [UnknownCryptorError] If the cryptor for data processing is + # not registered. + def decrypt(data) + raise NotImplementedError, 'Subclass should provide "decrypt" method implementation.' + end + end + end +end diff --git a/lib/pubnub/modules/crypto/cryptor.rb b/lib/pubnub/modules/crypto/cryptor.rb new file mode 100644 index 000000000..446c112b6 --- /dev/null +++ b/lib/pubnub/modules/crypto/cryptor.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Pubnub + module Crypto + # Encrypted data representation object. + # + # Objects contain both encrypted data and additional data created by cryptor + # that will be required to decrypt the data. + class EncryptedData + # Cryptor may provide here any information which will be useful when data + # should be decrypted. + # + # For example metadata may contain: + # * initialization vector + # * cipher key identifier + # * encrypted data length + # + # @return [String, nil] Cryptor-defined information. + attr_reader :metadata + + # Encrypted data. + # + # @return [String] Encrypted data. + attr_reader :data + + # Create encrypted data object. + # + # An object used to keep track of the results of data encryption and the + # additional data the cryptor needs to handle it later. + # + # @param data [String] Outcome of successful cryptor encrypt method + # call. + # @param metadata [String, nil] Additional information is provided by + # cryptor so that encrypted data can be handled later. + def initialize(data, metadata = nil) + @data = data + @metadata = metadata + end + end + + # Base class which is used to implement cryptor that should be used with + # CryptorProvider implementation for data encryption and decryption. + class Cryptor + # Identifier will be encoded into cryptor data header and passed along + # with encrypted data and metadata. + # + # The identifier must be 4 bytes long. + # + # @return [String] Unique cryptor identifier. + def identifier + raise NotImplementedError, 'Subclass should provide "identifier" method implementation.' + end + + # Encrypt provided data. + # + # @param data [String] Source data for encryption. + # @return [EncryptedData, nil] Encrypted data or nil in case of + # encryption error. + def encrypt(data) + raise NotImplementedError, 'Subclass should provide "encrypt" method implementation.' + end + + # Decrypt provided data. + # + # @param data [EncryptedData] Encrypted data for decryption. + # @return [String, nil] Decrypted data or nil in case of decryption + # error. + def decrypt(data) + raise NotImplementedError, 'Subclass should provide "decrypt" method implementation.' + end + end + end +end diff --git a/lib/pubnub/modules/crypto/cryptor_header.rb b/lib/pubnub/modules/crypto/cryptor_header.rb new file mode 100644 index 000000000..c77690d08 --- /dev/null +++ b/lib/pubnub/modules/crypto/cryptor_header.rb @@ -0,0 +1,251 @@ +module Pubnub + module Crypto + # Cryptor data header. + # + # This instance used to parse header from received data and encode into + # binary for sending. + class CryptorHeader + module Versions + # Currently used cryptor data header schema version. + CURRENT_VERSION = 1 + + # Base class for cryptor data schema. + class CryptorHeaderData + # Cryptor header version. + # + # @return [Integer] Cryptor header version. + def version + raise NotImplementedError, 'Subclass should provide "version" method implementation.' + end + + # Cryptor identifier. + # + # @return [String] Identifier of the cryptor which has been used to + # encrypt data. + def identifier + raise NotImplementedError, 'Subclass should provide "identifier" method implementation.' + end + + # Cryptor-defined data size. + # + # @return [Integer] Cryptor-defined data size. + def data_size + raise NotImplementedError, 'Subclass should provide "data_size" method implementation.' + end + end + + # v1 cryptor header schema. + # + # This header consists of: + # * sentinel (4 bytes) + # * version (1 byte) + # * cryptor identifier (4 bytes) + # * cryptor data size (1 byte if less than 255 and 3 bytes in other cases) + # * cryptor-defined data + class CryptorHeaderV1Data < CryptorHeaderData + # Identifier of the cryptor which has been used to encrypt data. + # + # @return [String] Identifier of the cryptor which has been used to + # encrypt data. + attr_reader :identifier + + # Cryptor-defined data size. + # + # @return [Integer] Cryptor-defined data size. + attr_reader :data_size + + # Create cryptor header data. + # + # @param identifier [String] Identifier of the cryptor which has been + # used to encrypt data. + # @param data_size [Integer] Cryptor-defined data size. + def initialize(identifier, data_size) + @identifier = identifier + @data_size = data_size + end + + def version + 1 + end + end + end + + # Create cryptor header. + # + # @param identifier [String] Identifier of the cryptor which has been used + # to encrypt data. + # @param metadata [String, nil] Cryptor-defined information. + def initialize(identifier = nil, metadata = nil) + @data = if identifier && identifier != '\x00\x00\x00\x00' + Versions::CryptorHeaderV1Data.new( + identifier.to_s, + metadata&.length || 0 + ) + end + end + + # Parse cryptor header data to create instance. + # + # @param data [String] Data which may contain cryptor header + # information. + # @return [CryptorHeader, nil] Header instance or nil in case of + # encrypted data parse error. + # + # @raise [ArgumentError] Raise an exception if data is nil + # or empty. + # @raise [UnknownCryptorError] Raise an exception if, during cryptor + # header data parsing, an unknown cryptor header version is encountered. + def self.parse(data) + if data.nil? || data.empty? + raise ArgumentError, { + message: '\'data\' is required and should not be empty.' + } + end + + # Data is too short to be encrypted. Assume legacy cryptor without + # header. + return CryptorHeader.new if data.length < 4 || data.unpack('A4').last != 'PNED' + + # Malformed crypto header. + return nil if data.length < 10 + + # Unpack header bytes. + _, version, identifier, data_size = data.unpack('A4 C A4 C') + + # Check whether version is within known range. + if version > current_version + raise UnknownCryptorError, { + message: 'Decrypting data created by unknown cryptor.' + } + end + + if data_size == 255 + data_size = data.unpack('A4 C A4 C n').last if data.length >= 12 + return CryptorHeader.new if data.length < 12 + end + + header = CryptorHeader.new + header.send( + :update_header_data, + create_header_data(version.to_i, identifier.to_s, data_size.to_i) + ) + header + end + + # Overall header size. + # + # Full header size which includes: + # * sentinel + # * version + # * cryptor identifier + # * cryptor data size + # * cryptor-defined fields size. + def length + # Legacy payload doesn't have header. + return 0 if @data.nil? + + 9 + (data_size < 255 ? 1 : 3) + end + + # Crypto header version Version module. + # + # @return [Integer] One of known versions from Version module. + def version + header_data&.version || 0 + end + + # Identifier of the cryptor which has been used to encrypt data. + # + # @return [String, nil] Identifier of the cryptor which has been used to + # encrypt data. + def identifier + header_data&.identifier || nil + end + + # Cryptor-defined information size. + # + # @return [Integer] Cryptor-defined information size. + def data_size + header_data&.data_size || 0 + end + + # Create cryptor header data object. + # + # @param version [Integer] Cryptor header data schema version. + # @param identifier [String] Encrypting cryptor identifier. + # @param size [Integer] Cryptor-defined data size + # @return [Versions::CryptorHeaderData] Cryptor header data. + def self.create_header_data(version, identifier, size) + Versions::CryptorHeaderV1Data.new(identifier, size) if version == 1 + end + + # Crypto header which is currently used to encrypt data. + # + # @return [Integer] Current cryptor header version. + def self.current_version + Versions::CURRENT_VERSION + end + + # Serialize cryptor header. + # + # @return [String] Cryptor header data, which is serialized as a binary + # string. + # + # @raise [ArgumentError] Raise an exception if a cryptor identifier + # is not provided for a non-legacy cryptor. + def to_s + # We don't need to serialize header for legacy cryptor. + return '' if version.zero? + + cryptor_identifier = identifier + if cryptor_identifier.nil? || cryptor_identifier.empty? + raise ArgumentError, { + message: '\'identifier\' is missing or empty.' + } + end + + header_bytes = ['PNED', version, cryptor_identifier] + if data_size < 255 + header_bytes.push(data_size) + else + header_bytes.push(255, data_size) + end + + header_bytes.pack(data_size < 255 ? 'A4 C A4 C' : 'A4 C A4 C n') + end + + private + + # Versioned cryptor header data + # + # @return [Versions::CryptorHeaderData, nil] Cryptor header data. + def header_data + @data + end + + # Update crypto header version. + # + # @param data [Versions::CryptorHeaderData] Header version number parsed from binary data. + def update_header_data(data) + @data = data + end + + # Update crypto header version. + # + # @param value [Integer] Header version number parsed from binary data. + def update_version(value) + @version = value + end + + # Update cryptor-defined data size. + # + # @param value [Integer] Cryptor-defined data size parsed from binary + # data. + def update_data_size(value) + @data_size = value + end + + private_class_method :create_header_data, :current_version + end + end +end diff --git a/lib/pubnub/modules/crypto/cryptors/aes_cbc_cryptor.rb b/lib/pubnub/modules/crypto/cryptors/aes_cbc_cryptor.rb new file mode 100644 index 000000000..2c37b4ef1 --- /dev/null +++ b/lib/pubnub/modules/crypto/cryptors/aes_cbc_cryptor.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Pubnub + module Crypto + # AES-256-CBC cryptor. + # + # The cryptor provides _encryption_ and _decryption_ using AES-256 in + # CBC mode with a cipher key and random initialization vector. + # When it is registered as a secondary with other cryptors, it will provide + # backward compatibility with previously encrypted data. + class AesCbcCryptor < Cryptor + # AES-128 CBC block size. + BLOCK_SIZE = 16 + + # Create AES-256-CBC cryptor instance. + # + # @param cipher_key [String] Key for data encryption and + # decryption. + def initialize(cipher_key) + @cipher_key = Digest::SHA256.digest(cipher_key) + @alg = 'AES-256-CBC' + super() + end + + def identifier + 'ACRH' + end + + def encrypt(data) + if data.nil? || data.empty? + puts 'Pubnub :: ENCRYPTION ERROR: Empty data for encryption' + return nil + end + + iv = OpenSSL::Random.random_bytes BLOCK_SIZE + cipher = OpenSSL::Cipher.new(@alg).encrypt + cipher.key = @cipher_key + cipher.iv = iv + + encoded_message = cipher.update data + encoded_message << cipher.final + Crypto::EncryptedData.new(encoded_message, iv) + rescue StandardError => e + puts "Pubnub :: ENCRYPTION ERROR: #{e}" + nil + end + + def decrypt(data) + if data.metadata.length != BLOCK_SIZE + puts "Pubnub :: DECRYPTION ERROR: Unexpected initialization vector length: +#{data.metadata.length} bytes (#{BLOCK_SIZE} bytes is expected)" + return nil + end + + cipher = OpenSSL::Cipher.new(@alg).decrypt + cipher.key = @cipher_key + cipher.iv = data.metadata + + decrypted = cipher.update data.data + decrypted << cipher.final + rescue StandardError => e + puts "Pubnub :: DECRYPTION ERROR: #{e}" + nil + end + end + end +end diff --git a/lib/pubnub/modules/crypto/cryptors/legacy_cryptor.rb b/lib/pubnub/modules/crypto/cryptors/legacy_cryptor.rb new file mode 100644 index 000000000..6782b30db --- /dev/null +++ b/lib/pubnub/modules/crypto/cryptors/legacy_cryptor.rb @@ -0,0 +1,84 @@ +module Pubnub + module Crypto + # Legacy cryptor. + # + # The cryptor provides _encryption_ and _decryption_ using `AES-256 in + # CBC mode with a cipher key and configurable initialization vector + # randomness. + # When it is registered as a secondary with other cryptors, it will provide + # backward compatibility with previously encrypted data. + # + # Important: It has been reported that the digest from cipherKey has + # low entropy, and it is suggested to use AesCbcCryptor instead. + class LegacyCryptor < Cryptor + # AES-128 CBC block size. + BLOCK_SIZE = 16 + + # Create legacy cryptor instance. + # + # @param cipher_key [String] Key for data encryption and + # decryption. + # @param use_random_iv [Boolean] Whether random IV should be used. + def initialize(cipher_key, use_random_iv = true) + @alg = 'AES-256-CBC' + @original_cipher_key = cipher_key + @cipher_key = Digest::SHA256.hexdigest(cipher_key.to_s).slice(0, 32) + @iv = use_random_iv ? nil : '0123456789012345' + super() + end + + def identifier + '\x00\x00\x00\x00' + end + + def encrypt(data) + if data.nil? || data.empty? + puts 'Pubnub :: ENCRYPTION ERROR: Empty data for encryption' + return nil + end + + iv = @iv || OpenSSL::Random.random_bytes(BLOCK_SIZE) + cipher = OpenSSL::Cipher.new(@alg).encrypt + cipher.key = @cipher_key + cipher.iv = iv + + encoded_message = '' + encoded_message << iv if @iv.nil? && iv + encoded_message << cipher.update(data) + encoded_message << cipher.final + Crypto::EncryptedData.new(encoded_message) + rescue StandardError => e + puts "Pubnub :: ENCRYPTION ERROR: #{e}" + nil + end + + def decrypt(data) + encrypted_data = data.data + iv = if @iv.nil? && encrypted_data.length >= BLOCK_SIZE + encrypted_data.slice!(0..(BLOCK_SIZE - 1)) if encrypted_data.length >= BLOCK_SIZE + else + @iv + end + if iv.length != BLOCK_SIZE + puts "Pubnub :: DECRYPTION ERROR: Unexpected initialization vector length: #{data.metadata.length} bytes (#{BLOCK_SIZE} bytes is expected)" + return nil + end + + unless encrypted_data.length.positive? + puts 'Pubnub :: DECRYPTION ERROR: Empty data for decryption' + return nil + end + + cipher = OpenSSL::Cipher.new(@alg).decrypt + cipher.key = @cipher_key + cipher.iv = iv + + decrypted = cipher.update encrypted_data + decrypted << cipher.final + rescue StandardError => e + puts "Pubnub :: DECRYPTION ERROR: #{e}" + nil + end + end + end +end diff --git a/lib/pubnub/modules/crypto/module.rb b/lib/pubnub/modules/crypto/module.rb new file mode 100644 index 000000000..c35a56561 --- /dev/null +++ b/lib/pubnub/modules/crypto/module.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require 'pubnub/modules/crypto/cryptor' +require 'pubnub/modules/crypto/cryptors/aes_cbc_cryptor' +require 'pubnub/modules/crypto/cryptors/legacy_cryptor' +require 'pubnub/modules/crypto/crypto_provider' +require 'pubnub/modules/crypto/cryptor_header' +require 'pubnub/modules/crypto/crypto_module' diff --git a/lib/pubnub/subscribe_event/formatter.rb b/lib/pubnub/subscribe_event/formatter.rb index 3d64d77c6..a460db538 100644 --- a/lib/pubnub/subscribe_event/formatter.rb +++ b/lib/pubnub/subscribe_event/formatter.rb @@ -33,13 +33,12 @@ def build_error_envelopes(_parsed_response, error, req_res_objects) end def decipher_payload(message) - return message[:payload] if message[:channel].end_with?('-pnpres') || (@app.env[:cipher_key].nil? && @cipher_key.nil? && @cipher_key_selector.nil? && @env[:cipher_key_selector].nil?) - data = message.reject { |k, _v| k == :payload } - cipher_key = compute_cipher_key(data) - random_iv = compute_random_iv(data) - crypto = Pubnub::Crypto.new(cipher_key, random_iv) - JSON.parse(crypto.decrypt(message[:payload]), quirks_mode: true) - rescue StandardError + # TODO: Uncomment code below when cryptor implementations will be added. + return message[:payload] if message[:channel].end_with?('-pnpres') || crypto_module.nil? + + encrypted_message = Base64.decode64(message[:payload]) + JSON.parse(crypto_module.decrypt(encrypted_message), quirks_mode: true) + rescue StandardError, UnknownCryptorError message[:payload] end @@ -51,7 +50,8 @@ def build_non_error_envelopes(parsed_response, req_res_objects) # STATUS envelopes = if messages.empty? [plain_envelope(req_res_objects, timetoken)] - else # RESULT + else + # RESULT messages.map do |message| encrypted_envelope(req_res_objects, message, timetoken) end diff --git a/lib/pubnub/version.rb b/lib/pubnub/version.rb index 290168aa7..11ae6978f 100644 --- a/lib/pubnub/version.rb +++ b/lib/pubnub/version.rb @@ -1,4 +1,4 @@ # Toplevel Pubnub module. module Pubnub - VERSION = '5.2.2'.freeze + VERSION = '5.3.0'.freeze end diff --git a/pubnub.gemspec b/pubnub.gemspec index 4102baa4a..0a5ee66f2 100644 --- a/pubnub.gemspec +++ b/pubnub.gemspec @@ -9,8 +9,8 @@ Gem::Specification.new do |spec| spec.email = ['support@pubnub.com'] spec.summary = 'PubNub Official Ruby gem.' spec.description = 'Ruby anywhere in the world in 250ms with PubNub!' - spec.homepage = 'http://github.com/pubnub/ruby' - spec.license = 'MIT' + spec.homepage = 'https://github.com/pubnub/ruby' + spec.licenses = %w[MIT LicenseRef-LICENSE] spec.files = `git ls-files -z`.split("\x0").grep_v(/^(test|spec|fixtures)/) spec.executables = spec.files.grep(%r{^bin\/}) { |f| File.basename(f) } diff --git a/spec/lib/multiple_ciphers_spec.rb b/spec/lib/multiple_ciphers_spec.rb index 5b86a59c3..4d46b7ff1 100644 --- a/spec/lib/multiple_ciphers_spec.rb +++ b/spec/lib/multiple_ciphers_spec.rb @@ -42,6 +42,8 @@ http_sync: true, ).first + puts '---------------------------------------' + expect(e0.result[:data][:message]).to eq "Some test message" expect(e1.result[:data][:message]).to eq "Another test message" end