aboutsummaryrefslogtreecommitdiffstats
path: root/activesupport/lib/active_support/encrypted_file.rb
blob: 2b7db568a50db114f0d385604591e412eb59d68e (plain) (blame)
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
# frozen_string_literal: true

require "pathname"
require "tmpdir"
require "active_support/message_encryptor"

module ActiveSupport
  class EncryptedFile
    class MissingContentError < RuntimeError
      def initialize(content_path)
        super "Missing encrypted content file in #{content_path}."
      end
    end

    class MissingKeyError < RuntimeError
      def initialize(key_path:, env_key:)
        super \
          "Missing encryption key to decrypt file with. " +
          "Ask your team for your master key and write it to #{key_path} or put it in the ENV['#{env_key}']."
      end
    end

    CIPHER = "aes-128-gcm"

    def self.generate_key
      SecureRandom.hex(ActiveSupport::MessageEncryptor.key_len(CIPHER))
    end


    attr_reader :content_path, :key_path, :env_key, :raise_if_missing_key

    def initialize(content_path:, key_path:, env_key:, raise_if_missing_key:)
      @content_path, @key_path = Pathname.new(content_path), Pathname.new(key_path)
      @env_key, @raise_if_missing_key = env_key, raise_if_missing_key
    end

    def key
      read_env_key || read_key_file || handle_missing_key
    end

    def read
      if !key.nil? && content_path.exist?
        decrypt content_path.binread
      else
        raise MissingContentError, content_path
      end
    end

    def write(contents)
      IO.binwrite "#{content_path}.tmp", encrypt(contents)
      FileUtils.mv "#{content_path}.tmp", content_path
    end

    def change(&block)
      writing read, &block
    end


    private
      def writing(contents)
        tmp_file = "#{Process.pid}.#{content_path.basename.to_s.chomp('.enc')}"
        tmp_path = Pathname.new File.join(Dir.tmpdir, tmp_file)
        tmp_path.binwrite contents

        yield tmp_path

        updated_contents = tmp_path.binread

        write(updated_contents) if updated_contents != contents
      ensure
        FileUtils.rm(tmp_path) if tmp_path&.exist?
      end


      def encrypt(contents)
        encryptor.encrypt_and_sign contents
      end

      def decrypt(contents)
        encryptor.decrypt_and_verify contents
      end

      def encryptor
        @encryptor ||= ActiveSupport::MessageEncryptor.new([ key ].pack("H*"), cipher: CIPHER)
      end


      def read_env_key
        ENV[env_key]
      end

      def read_key_file
        key_path.binread.strip if key_path.exist?
      end

      def handle_missing_key
        raise MissingKeyError, key_path: key_path, env_key: env_key if raise_if_missing_key
      end
  end
end