File: encryptor.rb

package info (click to toggle)
rails 2%3A7.2.2.1%2Bdfsg-7
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 43,352 kB
  • sloc: ruby: 349,799; javascript: 30,703; yacc: 46; sql: 43; sh: 29; makefile: 27
file content (170 lines) | stat: -rw-r--r-- 5,881 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
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
# frozen_string_literal: true

require "openssl"
require "zlib"
require "active_support/core_ext/numeric"

module ActiveRecord
  module Encryption
    # An encryptor exposes the encryption API that ActiveRecord::Encryption::EncryptedAttributeType
    # uses for encrypting and decrypting attribute values.
    #
    # It interacts with a KeyProvider for getting the keys, and delegate to
    # ActiveRecord::Encryption::Cipher the actual encryption algorithm.
    class Encryptor
      # === Options
      #
      # * <tt>:compress</tt> - Boolean indicating whether records should be compressed before encryption.
      #   Defaults to +true+.
      def initialize(compress: true)
        @compress = compress
      end

      # Encrypts +clean_text+ and returns the encrypted result
      #
      # Internally, it will:
      #
      # 1. Create a new ActiveRecord::Encryption::Message
      # 2. Compress and encrypt +clean_text+ as the message payload
      # 3. Serialize it with +ActiveRecord::Encryption.message_serializer+ (+ActiveRecord::Encryption::SafeMarshal+
      #    by default)
      # 4. Encode the result with Base 64
      #
      # === Options
      #
      # [:key_provider]
      #   Key provider to use for the encryption operation. It will default to
      #   +ActiveRecord::Encryption.key_provider+ when not provided.
      #
      # [:cipher_options]
      #   Cipher-specific options that will be passed to the Cipher configured in
      #   +ActiveRecord::Encryption.cipher+
      def encrypt(clear_text, key_provider: default_key_provider, cipher_options: {})
        clear_text = force_encoding_if_needed(clear_text) if cipher_options[:deterministic]

        validate_payload_type(clear_text)
        serialize_message build_encrypted_message(clear_text, key_provider: key_provider, cipher_options: cipher_options)
      end

      # Decrypts an +encrypted_text+ and returns the result as clean text
      #
      # === Options
      #
      # [:key_provider]
      #   Key provider to use for the encryption operation. It will default to
      #   +ActiveRecord::Encryption.key_provider+ when not provided
      #
      # [:cipher_options]
      #   Cipher-specific options that will be passed to the Cipher configured in
      #   +ActiveRecord::Encryption.cipher+
      def decrypt(encrypted_text, key_provider: default_key_provider, cipher_options: {})
        message = deserialize_message(encrypted_text)
        keys = key_provider.decryption_keys(message)
        raise Errors::Decryption unless keys.present?
        uncompress_if_needed(cipher.decrypt(message, key: keys.collect(&:secret), **cipher_options), message.headers.compressed)
      rescue *(ENCODING_ERRORS + DECRYPT_ERRORS)
        raise Errors::Decryption
      end

      # Returns whether the text is encrypted or not
      def encrypted?(text)
        deserialize_message(text)
        true
      rescue Errors::Encoding, *DECRYPT_ERRORS
        false
      end

      def binary?
        serializer.binary?
      end

      private
        DECRYPT_ERRORS = [OpenSSL::Cipher::CipherError, Errors::EncryptedContentIntegrity, Errors::Decryption]
        ENCODING_ERRORS = [EncodingError, Errors::Encoding]
        THRESHOLD_TO_JUSTIFY_COMPRESSION = 140.bytes

        def default_key_provider
          ActiveRecord::Encryption.key_provider
        end

        def validate_payload_type(clear_text)
          unless clear_text.is_a?(String)
            raise ActiveRecord::Encryption::Errors::ForbiddenClass, "The encryptor can only encrypt string values (#{clear_text.class})"
          end
        end

        def cipher
          ActiveRecord::Encryption.cipher
        end

        def build_encrypted_message(clear_text, key_provider:, cipher_options:)
          key = key_provider.encryption_key

          clear_text, was_compressed = compress_if_worth_it(clear_text)
          cipher.encrypt(clear_text, key: key.secret, **cipher_options).tap do |message|
            message.headers.add(key.public_tags)
            message.headers.compressed = true if was_compressed
          end
        end

        def serialize_message(message)
          serializer.dump(message)
        end

        def deserialize_message(message)
          serializer.load message
        rescue ArgumentError, TypeError, Errors::ForbiddenClass
          raise Errors::Encoding
        end

        def serializer
          ActiveRecord::Encryption.message_serializer
        end

        # Under certain threshold, ZIP compression is actually worse that not compressing
        def compress_if_worth_it(string)
          if compress? && string.bytesize > THRESHOLD_TO_JUSTIFY_COMPRESSION
            [compress(string), true]
          else
            [string, false]
          end
        end

        def compress?
          @compress
        end

        def compress(data)
          Zlib::Deflate.deflate(data).tap do |compressed_data|
            compressed_data.force_encoding(data.encoding)
          end
        end

        def uncompress_if_needed(data, compressed)
          if compressed
            uncompress(data)
          else
            data
          end
        end

        def uncompress(data)
          Zlib::Inflate.inflate(data).tap do |uncompressed_data|
            uncompressed_data.force_encoding(data.encoding)
          end
        end

        def force_encoding_if_needed(value)
          if forced_encoding_for_deterministic_encryption && value && value.encoding != forced_encoding_for_deterministic_encryption
            value.encode(forced_encoding_for_deterministic_encryption, invalid: :replace, undef: :replace)
          else
            value
          end
        end

        def forced_encoding_for_deterministic_encryption
          ActiveRecord::Encryption.config.forced_encoding_for_deterministic_encryption
        end
    end
  end
end