Module: KmsEncrypted::Model

Defined in:
lib/kms_encrypted/model.rb

Instance Method Summary collapse

Instance Method Details

#has_kms_key(name: nil, key_id: nil, eager_encrypt: false, version: 1, previous_versions: nil, upgrade_context: false) ⇒ Object



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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'lib/kms_encrypted/model.rb', line 3

def has_kms_key(name: nil, key_id: nil, eager_encrypt: false, version: 1, previous_versions: nil, upgrade_context: false)
  key_id ||= KmsEncrypted.key_id

  key_method = name ? "kms_key_#{name}" : "kms_key"
  key_column = "encrypted_#{key_method}"
  context_method = name ? "kms_encryption_context_#{name}" : "kms_encryption_context"

  class_eval do
    @kms_keys ||= {}

    unless respond_to?(:kms_keys)
      def self.kms_keys
        parent_keys =
          if superclass.respond_to?(:kms_keys)
            superclass.kms_keys
          else
            {}
          end

        parent_keys.merge(@kms_keys || {})
      end
    end

    @kms_keys[key_method.to_sym] = {
      key_id: key_id,
      name: name,
      version: version,
      previous_versions: previous_versions,
      upgrade_context: upgrade_context
    }

    if @kms_keys.size == 1
      after_save :encrypt_kms_keys

      # fetch all keys together so only need to update database once
      def encrypt_kms_keys
        updates = {}
        self.class.kms_keys.each do |key_method, key|
          instance_var = "@#{key_method}"
          key_column = "encrypted_#{key_method}"
          plaintext_key = instance_variable_get(instance_var)

          if !send(key_column) && plaintext_key
            updates[key_column] = KmsEncrypted::Database.new(self, key_method).encrypt(plaintext_key)
          end
        end
        if updates.any?
          current_time = current_time_from_proper_timezone
          timestamp_attributes_for_update_in_model.each do |attr|
            updates[attr] = current_time
          end
          update_columns(updates)
        end
      end

      if method_defined?(:reload)
        m = Module.new do
          define_method(:reload) do |*args, &block|
            result = super(*args, &block)
            self.class.kms_keys.keys.each do |key_method|
              instance_variable_set("@#{key_method}", nil)
            end
            result
          end
        end
        prepend m
      end
    end

    define_method(key_method) do
      instance_var = "@#{key_method}"

      unless instance_variable_get(instance_var)
        encrypted_key = send(key_column)
        plaintext_key =
          if encrypted_key
            KmsEncrypted::Database.new(self, key_method).decrypt(encrypted_key)
          else
            key = SecureRandom.random_bytes(32)

            if eager_encrypt == :fetch_id
              raise ArgumentError, ":fetch_id only works with Postgres" unless self.class.connection.adapter_name.match?(/postg/i)
              self.id ||= self.class.connection.execute("select nextval('#{self.class.sequence_name}')").first["nextval"]
            end

            if eager_encrypt == true || ([:try, :fetch_id].include?(eager_encrypt) && id)
              encrypted_key = KmsEncrypted::Database.new(self, key_method).encrypt(key)
              send("#{key_column}=", encrypted_key)
            end

            key
          end
        instance_variable_set(instance_var, plaintext_key)
      end

      instance_variable_get(instance_var)
    end

    define_method(context_method) do
      raise KmsEncrypted::Error, "id needed for encryption context" unless id

      {
        model_name: model_name.to_s,
        model_id: id
      }
    end

    # automatically detects attributes and files where the encryption key is:
    # 1. a symbol that matches kms key method exactly
    # does not detect attributes and files where the encryption key is:
    # 1. callable (warns)
    # 2. a symbol that internally calls kms key method
    # it could try to get the exact key and compare
    # (there's a very small chance this could have false positives)
    # but bias towards simplicity for now
    # TODO possibly raise error for callable keys in 2.0
    # with option to override/specify attributes
    define_method("rotate_#{key_method}!") do
      # decrypt
      plaintext_attributes = {}

      # attr_encrypted
      encrypted_attributes_method =
        if defined?(AttrEncrypted::Version::MAJOR) && AttrEncrypted::Version::MAJOR >= 4
          :attr_encrypted_encrypted_attributes
        else
          :encrypted_attributes
        end
      if self.class.respond_to?(encrypted_attributes_method)
        self.class.send(encrypted_attributes_method).to_a.each do |key, v|
          if v[:key] == key_method.to_sym
            plaintext_attributes[key] = send(key)
          elsif v[:key].respond_to?(:call)
            warn "[kms_encrypted] Can't detect if encrypted attribute uses this key"
          end
        end
      end

      # lockbox attributes
      # only checks key, not previous versions
      if self.class.respond_to?(:lockbox_attributes)
        self.class.lockbox_attributes.each do |key, v|
          if v[:key] == key_method.to_sym
            plaintext_attributes[key] = send(key)
          elsif v[:key].respond_to?(:call)
            warn "[kms_encrypted] Can't detect if encrypted attribute uses this key"
          end
        end
      end

      # lockbox attachments
      # only checks key, not previous versions
      if self.class.respond_to?(:lockbox_attachments)
        self.class.lockbox_attachments.each do |key, v|
          if v[:key] == key_method.to_sym
            # can likely add support at some point, but may be complicated
            # ideally use rotate_encryption! from Lockbox
            # but needs access to both old and new keys
            # also need to update database atomically
            raise KmsEncrypted::Error, "Can't rotate key used for encrypted files"
          elsif v[:key].respond_to?(:call)
            warn "[kms_encrypted] Can't detect if encrypted attachment uses this key"
          end
        end
      end

      # CarrierWave uploaders
      if self.class.respond_to?(:uploaders)
        self.class.uploaders.each do |_, uploader|
          # for simplicity, only checks if key is callable
          if uploader.respond_to?(:lockbox_options) && uploader.lockbox_options[:key].respond_to?(:call)
            warn "[kms_encrypted] Can't detect if encrypted uploader uses this key"
          end
        end
      end

      # reset key
      instance_variable_set("@#{key_method}", nil)
      send("encrypted_#{key_method}=", nil)

      # encrypt again
      plaintext_attributes.each do |attr, value|
        send("#{attr}=", value)
      end

      # update atomically
      save!
    end
  end
end