diff options
Diffstat (limited to 'lib/gitlab/encrypted_configuration.rb')
-rw-r--r-- | lib/gitlab/encrypted_configuration.rb | 121 |
1 files changed, 121 insertions, 0 deletions
diff --git a/lib/gitlab/encrypted_configuration.rb b/lib/gitlab/encrypted_configuration.rb new file mode 100644 index 00000000000..fe49af3ab33 --- /dev/null +++ b/lib/gitlab/encrypted_configuration.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +module Gitlab + class EncryptedConfiguration + delegate :[], :fetch, to: :config + delegate_missing_to :options + attr_reader :content_path, :key, :previous_keys + + CIPHER = "aes-256-gcm" + SALT = "GitLabEncryptedConfigSalt" + + class MissingKeyError < RuntimeError + def initialize(msg = "Missing encryption key to encrypt/decrypt file with.") + super + end + end + + class InvalidConfigError < RuntimeError + def initialize(msg = "Content was not a valid yml config file") + super + end + end + + def self.generate_key(base_key) + # Because the salt is static, we want uniqueness to be coming from the base_key + # Error if the base_key is empty or suspiciously short + raise 'Base key too small' if base_key.blank? || base_key.length < 16 + + ActiveSupport::KeyGenerator.new(base_key).generate_key(SALT, ActiveSupport::MessageEncryptor.key_len(CIPHER)) + end + + def initialize(content_path: nil, base_key: nil, previous_keys: []) + @content_path = Pathname.new(content_path).yield_self { |path| path.symlink? ? path.realpath : path } if content_path + @key = self.class.generate_key(base_key) if base_key + @previous_keys = previous_keys + end + + def active? + content_path&.exist? + end + + def read + if active? + decrypt(content_path.binread) + else + "" + end + end + + def write(contents) + # ensure contents are valid to deserialize before write + deserialize(contents) + + temp_file = Tempfile.new(File.basename(content_path), File.dirname(content_path)) + File.open(temp_file.path, 'wb') do |file| + file.write(encrypt(contents)) + end + FileUtils.mv(temp_file.path, content_path) + ensure + temp_file&.unlink + end + + def config + return @config if @config + + contents = deserialize(read) + + raise InvalidConfigError.new unless contents.is_a?(Hash) + + @config = contents.deep_symbolize_keys + end + + def change(&block) + writing(read, &block) + end + + private + + def writing(contents) + updated_contents = yield contents + + write(updated_contents) if updated_contents != contents + end + + def encrypt(contents) + handle_missing_key! + encryptor.encrypt_and_sign(contents) + end + + def decrypt(contents) + handle_missing_key! + encryptor.decrypt_and_verify(contents) + end + + def encryptor + return @encryptor if @encryptor + + @encryptor = ActiveSupport::MessageEncryptor.new(key, cipher: CIPHER) + + # Allow fallback to previous keys + @previous_keys.each do |key| + @encryptor.rotate(self.class.generate_key(key)) + end + + @encryptor + end + + def options + # Allows top level keys to be referenced using dot syntax + @options ||= ActiveSupport::InheritableOptions.new(config) + end + + def deserialize(contents) + YAML.safe_load(contents, permitted_classes: [Symbol]).presence || {} + end + + def handle_missing_key! + raise MissingKeyError.new if @key.nil? + end + end +end |