1# frozen_string_literal: true 2 3module Gitlab 4 class EncryptedConfiguration 5 delegate :[], :fetch, to: :config 6 delegate_missing_to :options 7 attr_reader :content_path, :key, :previous_keys 8 9 CIPHER = "aes-256-gcm" 10 SALT = "GitLabEncryptedConfigSalt" 11 12 class MissingKeyError < RuntimeError 13 def initialize(msg = "Missing encryption key to encrypt/decrypt file with.") 14 super 15 end 16 end 17 18 class InvalidConfigError < RuntimeError 19 def initialize(msg = "Content was not a valid yml config file") 20 super 21 end 22 end 23 24 def self.generate_key(base_key) 25 # Because the salt is static, we want uniqueness to be coming from the base_key 26 # Error if the base_key is empty or suspiciously short 27 raise 'Base key too small' if base_key.blank? || base_key.length < 16 28 29 ActiveSupport::KeyGenerator.new(base_key).generate_key(SALT, ActiveSupport::MessageEncryptor.key_len(CIPHER)) 30 end 31 32 def initialize(content_path: nil, base_key: nil, previous_keys: []) 33 @content_path = Pathname.new(content_path).yield_self { |path| path.symlink? ? path.realpath : path } if content_path 34 @key = self.class.generate_key(base_key) if base_key 35 @previous_keys = previous_keys 36 end 37 38 def active? 39 content_path&.exist? 40 end 41 42 def read 43 if active? 44 decrypt(content_path.binread) 45 else 46 "" 47 end 48 end 49 50 def write(contents) 51 # ensure contents are valid to deserialize before write 52 deserialize(contents) 53 54 temp_file = Tempfile.new(File.basename(content_path), File.dirname(content_path)) 55 File.open(temp_file.path, 'wb') do |file| 56 file.write(encrypt(contents)) 57 end 58 FileUtils.mv(temp_file.path, content_path) 59 ensure 60 temp_file&.unlink 61 end 62 63 def config 64 return @config if @config 65 66 contents = deserialize(read) 67 68 raise InvalidConfigError unless contents.is_a?(Hash) 69 70 @config = contents.deep_symbolize_keys 71 end 72 73 def change(&block) 74 writing(read, &block) 75 end 76 77 private 78 79 def writing(contents) 80 updated_contents = yield contents 81 82 write(updated_contents) if updated_contents != contents 83 end 84 85 def encrypt(contents) 86 handle_missing_key! 87 encryptor.encrypt_and_sign(contents) 88 end 89 90 def decrypt(contents) 91 handle_missing_key! 92 encryptor.decrypt_and_verify(contents) 93 end 94 95 def encryptor 96 return @encryptor if @encryptor 97 98 @encryptor = ActiveSupport::MessageEncryptor.new(key, cipher: CIPHER) 99 100 # Allow fallback to previous keys 101 @previous_keys.each do |key| 102 @encryptor.rotate(self.class.generate_key(key)) 103 end 104 105 @encryptor 106 end 107 108 def options 109 # Allows top level keys to be referenced using dot syntax 110 @options ||= ActiveSupport::InheritableOptions.new(config) 111 end 112 113 def deserialize(contents) 114 YAML.safe_load(contents, permitted_classes: [Symbol]).presence || {} 115 end 116 117 def handle_missing_key! 118 raise MissingKeyError if @key.nil? 119 end 120 end 121end 122