Commit 075a22f2 authored by DJ Mountney's avatar DJ Mountney

Add new secret key for encrypted configuration

- Adds support for a new (optional) secret key from secrets.yml
- Adds a encrypted configuration class than can decrypt/encrypt files
  using the provided key
- Adds a new encrypted setting helper method to Settings for the new
  secret key
parent bb5d25a9
......@@ -152,6 +152,16 @@ class Settings < Settingslogic
Gitlab::Application.secrets.db_key_base
end
def encrypted(path)
return Gitlab::EncryptedConfiguration.new unless Gitlab::Application.secrets.enc_settings_key_base
Gitlab::EncryptedConfiguration.new(
content_path: Settings.absolute(path),
key: Gitlab::Application.secrets.enc_settings_key_base,
previous_keys: Gitlab::Application.secrets.rotated_enc_settings_key_base || []
)
end
def load_dynamic_cron_schedules!
cron_jobs['gitlab_usage_ping_worker']['cron'] ||= cron_for_usage_ping
end
......
......@@ -16,6 +16,7 @@ This page is a development guide for application secrets.
| `otp_key_base` | The base key for One Time Passwords, described in [User management](../raketasks/user_management.md#rotate-two-factor-authentication-encryption-key) |
|`db_key_base` | The base key to encrypt the data for `attr_encrypted` columns |
|`openid_connect_signing_key` | The singing key for OpenID Connect |
| `enc_settings_key_base` | The base key to encrypt settings files with |
## Where the secrets are stored
......
# 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-128-gcm"
def initialize(content_path: nil, key: nil, previous_keys: [])
@content_path = Pathname.new(content_path).yield_self { |path| path.symlink? ? path.realpath : path } if content_path
@key = key
@previous_keys = previous_keys
end
def read
if !key.nil? && content_path&.exist?
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
@config ||= deserialize(read).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)
encryptor.encrypt_and_sign contents
end
def decrypt(contents)
encryptor.decrypt_and_verify contents
end
def encryptor
return @encryptor if @encryptor
@encryptor = ActiveSupport::MessageEncryptor.new([key].pack("H*"), cipher: CIPHER)
# Allow fallback to previous keys
@previous_keys.each do |key|
@encryptor.rotate([key].pack("H*"))
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
end
end
......@@ -134,4 +134,25 @@ RSpec.describe Settings do
end
end
end
describe '.encrypted' do
before do
allow(Gitlab::Application.secrets).to receive(:enc_settings_key_base).and_return(SecureRandom.hex(64))
end
it 'defaults to using the enc_settings_key_base for the key' do
expect(Gitlab::EncryptedConfiguration).to receive(:new).with(hash_including(key: Gitlab::Application.secrets.enc_settings_key_base))
Settings.encrypted('tmp/tests/test.enc')
end
it 'defaults the configpath within the rails root' do
expect(Settings.encrypted('tmp/tests/test.enc').content_path.fnmatch?(File.join(Rails.root, '**'))).to be true
end
it 'returns empty encrypted config when a key has not been set' do
allow(Gitlab::Application.secrets).to receive(:enc_settings_key_base).and_return(nil)
expect(Gitlab::EncryptedConfiguration).to receive(:new).with(no_args)
Settings.encrypted('tmp/tests/test.enc')
end
end
end
# frozen_string_literal: true
require "spec_helper"
RSpec.describe Gitlab::EncryptedConfiguration do
subject(:configuration) { described_class.new }
describe '#initialize' do
it 'accepts all args as optional fields' do
expect { configuration }.not_to raise_exception
expect(configuration.key).to be_nil
expect(configuration.previous_keys).to be_empty
end
end
context 'when provided key and config file' do
let!(:config_tmp_dir) { Dir.mktmpdir('config-') }
let(:credentials_config_path) { File.join(config_tmp_dir, 'credentials.yml.enc') }
let(:credentials_key) { ActiveSupport::EncryptedConfiguration.generate_key }
after do
FileUtils.rm_f(config_tmp_dir)
end
describe '#write' do
it 'encrypts the file using the provided key' do
encryptor = ActiveSupport::MessageEncryptor.new([credentials_key].pack('H*'), cipher: 'aes-128-gcm')
config = described_class.new(content_path: credentials_config_path, key: credentials_key)
config.write('sample-content')
expect(encryptor.decrypt_and_verify(File.read(credentials_config_path))).to eq('sample-content')
end
end
describe '#read' do
it 'reads yaml configuration' do
config = described_class.new(content_path: credentials_config_path, key: credentials_key)
config.write({ foo: { bar: true } }.to_yaml)
expect(config[:foo][:bar]).to be true
end
it 'allows referencing top level keys via dot syntax' do
config = described_class.new(content_path: credentials_config_path, key: credentials_key)
config.write({ foo: { bar: true } }.to_yaml)
expect(config.foo[:bar]).to be true
end
end
describe '#change' do
it 'changes yaml configuration' do
config = described_class.new(content_path: credentials_config_path, key: credentials_key)
config.write({ foo: { bar: true } }.to_yaml)
config.change do |unencrypted_contents|
contents = YAML.safe_load(unencrypted_contents, permitted_classes: [Symbol])
contents.merge(beef: "stew").to_yaml
end
expect(config.foo[:bar]).to be true
expect(config.beef).to eq('stew')
end
end
context 'when provided previous_keys for rotation' do
let!(:config_tmp_dir) { Dir.mktmpdir('config-') }
let(:credential_key_original) { ActiveSupport::EncryptedConfiguration.generate_key }
let(:credential_key_latest) { ActiveSupport::EncryptedConfiguration.generate_key }
let(:config_path_original) { File.join(config_tmp_dir, 'credentials-orig.yml.enc') }
let(:config_path_latest) { File.join(config_tmp_dir, 'credentials-latest.yml.enc') }
after do
FileUtils.rm_f(config_tmp_dir)
end
def encryptor(key)
ActiveSupport::MessageEncryptor.new([key].pack('H*'), cipher: 'aes-128-gcm')
end
describe '#write' do
it 'rotates the key when provided a new key' do
config1 = described_class.new(content_path: config_path_original, key: credential_key_original)
config1.write('sample-content1')
config2 = described_class.new(content_path: config_path_latest, key: credential_key_latest, previous_keys: [credential_key_original])
config2.write('sample-content2')
original_key_encryptor = encryptor(credential_key_original) # can read with the initial key
latest_key_encryptor = encryptor(credential_key_latest) # can read with the new key
both_key_encryptor = encryptor(credential_key_latest) # can read with either key
both_key_encryptor.rotate([credential_key_original].pack("H*"))
expect(original_key_encryptor.decrypt_and_verify(File.read(config_path_original))).to eq('sample-content1')
expect(both_key_encryptor.decrypt_and_verify(File.read(config_path_original))).to eq('sample-content1')
expect(latest_key_encryptor.decrypt_and_verify(File.read(config_path_latest))).to eq('sample-content2')
expect(both_key_encryptor.decrypt_and_verify(File.read(config_path_latest))).to eq('sample-content2')
expect { original_key_encryptor.decrypt_and_verify(File.read(config_path_latest)) }.to raise_error(ActiveSupport::MessageEncryptor::InvalidMessage)
end
end
describe '#read' do
it 'supports reading using rotated config' do
described_class.new(content_path: config_path_original, key: credential_key_original).write({ foo: { bar: true } }.to_yaml)
config = described_class.new(content_path: config_path_original, key: credential_key_latest, previous_keys: [credential_key_original])
expect(config[:foo][:bar]).to be true
end
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment