summaryrefslogtreecommitdiff
path: root/lib/gitlab/encrypted_configuration.rb
diff options
context:
space:
mode:
Diffstat (limited to 'lib/gitlab/encrypted_configuration.rb')
-rw-r--r--lib/gitlab/encrypted_configuration.rb121
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