Add credentials using a generic EncryptedConfiguration class (#30067)
* WIP: Add credentials using a generic EncryptedConfiguration class This is sketch code so far. * Flesh out EncryptedConfiguration and test it * Better name * Add command and generator for credentials * Use the Pathnames * Extract EncryptedFile from EncryptedConfiguration and add serializers * Test EncryptedFile * Extract serializer validation * Stress the point about losing comments * Allow encrypted configuration to be read without parsing for display * Use credentials by default and base them on the master key * Derive secret_key_base in test/dev, source it from credentials in other envs And document the usage. * Document the new credentials setup * Stop generating the secrets.yml file now that we have credentials * Document what we should have instead Still need to make it happen, tho. * [ci skip] Keep wording to `key base`; prefer defaults. Usually we say we change defaults, not "spec" out a release. Can't use backticks in our sdoc generated documentation either. * Abstract away OpenSSL; prefer MessageEncryptor. * Spare needless new when raising. * Encrypted file test shouldn't depend on subclass. * [ci skip] Some woordings. * Ditch serializer future coding. * I said flip it. Flip it good. * [ci skip] Move require_master_key to the real production.rb. * Add require_master_key to abort the boot process. In case the master key is required in a certain environment we should inspect that the key is there and abort if it isn't. * Print missing key message and exit immediately. Spares us a lengthy backtrace and prevents further execution. I've verified the behavior in a test app, but couldn't figure the test out as loading the app just exits immediately with: ``` /Users/kasperhansen/Documents/code/rails/activesupport/lib/active_support/testing/isolation.rb:23:in `load': marshal data too short (ArgumentError) from /Users/kasperhansen/Documents/code/rails/activesupport/lib/active_support/testing/isolation.rb:23:in `run' from /Users/kasperhansen/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/minitest-5.10.2/lib/minitest.rb:830:in `run_one_method' from /Users/kasperhansen/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/minitest-5.10.2/lib/minitest/parallel.rb:32:in `block (2 levels) in start' ``` It's likely we need to capture and prevent the exit somehow. Kernel.stub(:exit) didn't work. Leaving it for tomorrow. * Fix require_master_key config test. Loading the app would trigger the `exit 1` per require_master_key's semantics, which then aborted the test. Fork and wait for the child process to finish, then inspect the exit status. Also check we aborted because of a missing master key, so something else didn't just abort the boot. Much <3 to @tenderlove for the tip. * Support reading/writing configs via methods. * Skip needless deep symbolizing. * Remove save; test config reader elsewhere. * Move secret_key_base check to when we're reading it. Otherwise we'll abort too soon since we don't assign the secret_key_base to secrets anymore. * Add missing string literal comments; require unneeded yaml require. * ya ya ya, rubocop. * Add master_key/credentials after bundle. Then we can reuse the existing message on `rails new bc4`. It'll look like: ``` Using web-console 3.5.1 from https://github.com/rails/web-console.git (at master@ce985eb) Using rails 5.2.0.alpha from source at `/Users/kasperhansen/Documents/code/rails` Using sass-rails 5.0.6 Bundle complete! 16 Gemfile dependencies, 72 gems now installed. Use `bundle info [gemname]` to see where a bundled gem is installed. Adding config/master.key to store the master encryption key: 97070158c44b4675b876373a6bc9d5a0 Save this in a password manager your team can access. If you lose the key, no one, including you, can access anything encrypted with it. create config/master.key ``` And that'll be executed even if `--skip-bundle` was passed. * Ensure test app has secret_key_base. * Assign secret_key_base to app or omit. * Merge noise * Split options for dynamic delegation into its own method and use deep symbols to make it work * Update error to point to credentials instead * Appease Rubocop * Validate secret_key_base when reading it. Instead of relying on the validation in key_generator move that into secret_key_base itself. * Fix generator and secrets test. Manually add config.read_encrypted_secrets since it's not there by default anymore. Move mentions of config/secrets.yml to config/credentials.yml.enc. * Remove files I have no idea how they got here. * [ci skip] swap secrets for credentials. * [ci skip] And now, changelogs are coming.
This commit is contained in:
parent
80573a099e
commit
69f976b859
@ -33,12 +33,12 @@ module Session
|
||||
#
|
||||
# Rails.application.config.session_store :cookie_store, key: '_your_app_session'
|
||||
#
|
||||
# Configure your secret key in <tt>config/secrets.yml</tt>:
|
||||
# By default, your secret key base is derived from your application name in
|
||||
# the test and development environments. In all other environments, it is stored
|
||||
# encrypted in the <tt>config/credentials.yml.enc</tt> file.
|
||||
#
|
||||
# development:
|
||||
# secret_key_base: 'secret key'
|
||||
#
|
||||
# To generate a secret key for an existing application, run <tt>rails secret</tt>.
|
||||
# If your application was not updated to Rails 5.2 defaults, the secret_key_base
|
||||
# will be found in the old <tt>config/secrets.yml</tt> file.
|
||||
#
|
||||
# If you are upgrading an existing Rails 3 app, you should leave your
|
||||
# existing secret_token in place and simply add the new secret_key_base.
|
||||
|
@ -29,11 +29,9 @@ class Engine < Rails::Engine # :nodoc:
|
||||
|
||||
initializer "active_storage.verifier" do
|
||||
config.after_initialize do |app|
|
||||
if app.secrets.secret_key_base.present?
|
||||
ActiveStorage.verifier = app.message_verifier("ActiveStorage")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
initializer "active_storage.services" do
|
||||
config.to_prepare do
|
||||
|
@ -1,3 +1,22 @@
|
||||
* Add `config/credentials.yml.enc` to store production app secrets.
|
||||
|
||||
Allows saving any authentication credentials for third party services
|
||||
directly in repo encrypted with `config/master.key` or `ENV["RAILS_MASTER_KEY"]`.
|
||||
|
||||
This will eventually replace `Rails.application.secrets` and the encrypted
|
||||
secrets introduced in Rails 5.1.
|
||||
|
||||
*DHH*, *Kasper Timm Hansen*
|
||||
|
||||
* Add `ActiveSupport::EncryptedFile` and `ActiveSupport::EncryptedConfiguration`.
|
||||
|
||||
Allows for stashing encrypted files or configuration directly in repo by
|
||||
encrypting it with a key.
|
||||
|
||||
Backs the new credentials setup above, but can also be used independently.
|
||||
|
||||
*DHH*, *Kasper Timm Hansen*
|
||||
|
||||
* `Module#delegate_missing_to` now raises `DelegationError` if target is nil,
|
||||
similar to `Module#delegate`.
|
||||
|
||||
|
42
activesupport/lib/active_support/encrypted_configuration.rb
Normal file
42
activesupport/lib/active_support/encrypted_configuration.rb
Normal file
@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "yaml"
|
||||
require "active_support/encrypted_file"
|
||||
require "active_support/ordered_options"
|
||||
require "active_support/core_ext/object/inclusion"
|
||||
require "active_support/core_ext/module/delegation"
|
||||
|
||||
module ActiveSupport
|
||||
class EncryptedConfiguration < EncryptedFile
|
||||
delegate :[], :fetch, to: :config
|
||||
delegate_missing_to :options
|
||||
|
||||
def initialize(config_path:, key_path:, env_key:)
|
||||
super content_path: config_path, key_path: key_path, env_key: env_key
|
||||
end
|
||||
|
||||
# Allow a config to be started without a file present
|
||||
def read
|
||||
super
|
||||
rescue ActiveSupport::EncryptedFile::MissingContentError
|
||||
""
|
||||
end
|
||||
|
||||
def config
|
||||
@config ||= deserialize(read).deep_symbolize_keys
|
||||
end
|
||||
|
||||
private
|
||||
def options
|
||||
@options ||= ActiveSupport::InheritableOptions.new(config)
|
||||
end
|
||||
|
||||
def serialize(config)
|
||||
config.present? ? YAML.dump(config) : ""
|
||||
end
|
||||
|
||||
def deserialize(config)
|
||||
config.present? ? YAML.load(config) : {}
|
||||
end
|
||||
end
|
||||
end
|
101
activesupport/lib/active_support/encrypted_file.rb
Normal file
101
activesupport/lib/active_support/encrypted_file.rb
Normal file
@ -0,0 +1,101 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "pathname"
|
||||
require "active_support/message_encryptor"
|
||||
require "active_support/core_ext/string/strip"
|
||||
require "active_support/core_ext/module/delegation"
|
||||
|
||||
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
|
||||
|
||||
def initialize(content_path:, key_path:, env_key:)
|
||||
@content_path, @key_path = Pathname.new(content_path), Pathname.new(key_path)
|
||||
@env_key = env_key
|
||||
end
|
||||
|
||||
def key
|
||||
read_env_key || read_key_file || handle_missing_key
|
||||
end
|
||||
|
||||
def read
|
||||
if 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 = "#{content_path.basename}.#{Process.pid}"
|
||||
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
|
||||
end
|
||||
end
|
||||
end
|
@ -49,6 +49,17 @@ class Railtie < Rails::Railtie # :nodoc:
|
||||
Date.beginning_of_week_default = beginning_of_week_default
|
||||
end
|
||||
|
||||
initializer "active_support.require_master_key" do |app|
|
||||
if app.config.respond_to?(:require_master_key) && app.config.require_master_key
|
||||
begin
|
||||
app.credentials.key
|
||||
rescue ActiveSupport::EncryptedFile::MissingKeyError => error
|
||||
$stderr.puts error.message
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
initializer "active_support.set_configs" do |app|
|
||||
app.config.active_support.each do |k, v|
|
||||
k = "#{k}="
|
||||
|
65
activesupport/test/encrypted_configuration_test.rb
Normal file
65
activesupport/test/encrypted_configuration_test.rb
Normal file
@ -0,0 +1,65 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "abstract_unit"
|
||||
require "active_support/encrypted_configuration"
|
||||
|
||||
class EncryptedConfigurationTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@credentials_config_path = File.join(Dir.tmpdir, "credentials.yml.enc")
|
||||
|
||||
@credentials_key_path = File.join(Dir.tmpdir, "master.key")
|
||||
File.write(@credentials_key_path, ActiveSupport::EncryptedConfiguration.generate_key)
|
||||
|
||||
@credentials = ActiveSupport::EncryptedConfiguration.new \
|
||||
config_path: @credentials_config_path, key_path: @credentials_key_path, env_key: "RAILS_MASTER_KEY"
|
||||
end
|
||||
|
||||
teardown do
|
||||
FileUtils.rm_rf @credentials_config_path
|
||||
FileUtils.rm_rf @credentials_key_path
|
||||
end
|
||||
|
||||
test "reading configuration by env key" do
|
||||
FileUtils.rm_rf @credentials_key_path
|
||||
|
||||
begin
|
||||
ENV["RAILS_MASTER_KEY"] = ActiveSupport::EncryptedConfiguration.generate_key
|
||||
@credentials.write({ something: { good: true, bad: false } }.to_yaml)
|
||||
|
||||
assert @credentials[:something][:good]
|
||||
assert_not @credentials.dig(:something, :bad)
|
||||
assert_nil @credentials.fetch(:nothing, nil)
|
||||
ensure
|
||||
ENV["RAILS_MASTER_KEY"] = nil
|
||||
end
|
||||
end
|
||||
|
||||
test "reading configuration by key file" do
|
||||
@credentials.write({ something: { good: true } }.to_yaml)
|
||||
|
||||
assert @credentials.something[:good]
|
||||
end
|
||||
|
||||
test "change configuration by key file" do
|
||||
@credentials.write({ something: { good: true } }.to_yaml)
|
||||
@credentials.change do |config_file|
|
||||
config = YAML.load(config_file.read)
|
||||
config_file.write config.merge(new: "things").to_yaml
|
||||
end
|
||||
|
||||
assert @credentials.something[:good]
|
||||
assert_equal "things", @credentials[:new]
|
||||
end
|
||||
|
||||
test "raises key error when accessing config via bang method" do
|
||||
assert_raise(KeyError) { @credentials.something! }
|
||||
end
|
||||
|
||||
private
|
||||
def new_credentials_configuration
|
||||
ActiveSupport::EncryptedConfiguration.new \
|
||||
config_path: @credentials_config_path,
|
||||
key_path: @credentials_key_path,
|
||||
env_key: "RAILS_MASTER_KEY"
|
||||
end
|
||||
end
|
50
activesupport/test/encrypted_file_test.rb
Normal file
50
activesupport/test/encrypted_file_test.rb
Normal file
@ -0,0 +1,50 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "abstract_unit"
|
||||
require "active_support/encrypted_file"
|
||||
|
||||
class EncryptedFileTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@content = "One little fox jumped over the hedge"
|
||||
|
||||
@content_path = File.join(Dir.tmpdir, "content.txt.enc")
|
||||
|
||||
@key_path = File.join(Dir.tmpdir, "content.txt.key")
|
||||
File.write(@key_path, ActiveSupport::EncryptedFile.generate_key)
|
||||
|
||||
@encrypted_file = ActiveSupport::EncryptedFile.new \
|
||||
content_path: @content_path, key_path: @key_path, env_key: "CONTENT_KEY"
|
||||
end
|
||||
|
||||
teardown do
|
||||
FileUtils.rm_rf @content_path
|
||||
FileUtils.rm_rf @key_path
|
||||
end
|
||||
|
||||
test "reading content by env key" do
|
||||
FileUtils.rm_rf @key_path
|
||||
|
||||
begin
|
||||
ENV["CONTENT_KEY"] = ActiveSupport::EncryptedFile.generate_key
|
||||
@encrypted_file.write @content
|
||||
|
||||
assert_equal @content, @encrypted_file.read
|
||||
ensure
|
||||
ENV["CONTENT_KEY"] = nil
|
||||
end
|
||||
end
|
||||
|
||||
test "reading content by key file" do
|
||||
@encrypted_file.write(@content)
|
||||
assert_equal @content, @encrypted_file.read
|
||||
end
|
||||
|
||||
test "change content by key file" do
|
||||
@encrypted_file.write(@content)
|
||||
@encrypted_file.change do |file|
|
||||
file.write(file.read + " and went by the lake")
|
||||
end
|
||||
|
||||
assert_equal "#{@content} and went by the lake", @encrypted_file.read
|
||||
end
|
||||
end
|
@ -1,3 +1,9 @@
|
||||
* Derive `secret_key_base` from the app name in development and test environments.
|
||||
|
||||
Spares away needless secret configs.
|
||||
|
||||
*DHH*, *Kasper Timm Hansen*
|
||||
|
||||
* Support multiple versions arguments for `gem` method of Generators.
|
||||
|
||||
*Yoshiyuki Hirano*
|
||||
|
@ -5,6 +5,7 @@
|
||||
require "active_support/core_ext/object/blank"
|
||||
require "active_support/key_generator"
|
||||
require "active_support/message_verifier"
|
||||
require "active_support/encrypted_configuration"
|
||||
require_relative "engine"
|
||||
require_relative "secrets"
|
||||
|
||||
@ -171,12 +172,9 @@ def key_generator
|
||||
# number of iterations selected based on consultation with the google security
|
||||
# team. Details at https://github.com/rails/rails/pull/6952#issuecomment-7661220
|
||||
@caching_key_generator ||=
|
||||
if secrets.secret_key_base
|
||||
unless secrets.secret_key_base.kind_of?(String)
|
||||
raise ArgumentError, "`secret_key_base` for #{Rails.env} environment must be a type of String, change this value in `config/secrets.yml`"
|
||||
end
|
||||
key_generator = ActiveSupport::KeyGenerator.new(secrets.secret_key_base, iterations: 1000)
|
||||
ActiveSupport::CachingKeyGenerator.new(key_generator)
|
||||
if secret_key_base
|
||||
ActiveSupport::CachingKeyGenerator.new \
|
||||
ActiveSupport::KeyGenerator.new(secret_key_base, iterations: 1000)
|
||||
else
|
||||
ActiveSupport::LegacyKeyGenerator.new(secrets.secret_token)
|
||||
end
|
||||
@ -246,13 +244,11 @@ def config_for(name, env: Rails.env)
|
||||
# will be used by middlewares and engines to configure themselves.
|
||||
def env_config
|
||||
@app_env_config ||= begin
|
||||
validate_secret_key_config!
|
||||
|
||||
super.merge(
|
||||
"action_dispatch.parameter_filter" => config.filter_parameters,
|
||||
"action_dispatch.redirect_filter" => config.filter_redirect,
|
||||
"action_dispatch.secret_token" => secrets.secret_token,
|
||||
"action_dispatch.secret_key_base" => secrets.secret_key_base,
|
||||
"action_dispatch.secret_key_base" => secret_key_base,
|
||||
"action_dispatch.show_exceptions" => config.action_dispatch.show_exceptions,
|
||||
"action_dispatch.show_detailed_exceptions" => config.consider_all_requests_local,
|
||||
"action_dispatch.logger" => Rails.logger,
|
||||
@ -406,6 +402,33 @@ def secrets=(secrets) #:nodoc:
|
||||
@secrets = secrets
|
||||
end
|
||||
|
||||
# The secret_key_base is used as the input secret to the application's key generator, which in turn
|
||||
# is used to create all the MessageVerfiers, including the one that signs and encrypts cookies.
|
||||
#
|
||||
# In test and development, this is simply derived as a MD5 hash of the application's name.
|
||||
#
|
||||
# In all other environments, we look for it first in ENV["SECRET_KEY_BASE"],
|
||||
# then credentials[:secret_key_base], and finally secrets.secret_key_base. For most applications,
|
||||
# the correct place to store it is in the encrypted credentials file.
|
||||
def secret_key_base
|
||||
if Rails.env.test? || Rails.env.development?
|
||||
Digest::MD5.hexdigest self.class.name
|
||||
else
|
||||
validate_secret_key_base \
|
||||
ENV["SECRET_KEY_BASE"] || credentials.secret_key_base || secrets.secret_key_base
|
||||
end
|
||||
end
|
||||
|
||||
# Decrypts the credentials hash as kept in `config/credentials.yml.enc`. This file is encrypted with
|
||||
# the Rails master key, which is either taken from ENV["RAILS_MASTER_KEY"] or from loading
|
||||
# `config/master.key`.
|
||||
def credentials
|
||||
@credentials ||= ActiveSupport::EncryptedConfiguration.new \
|
||||
config_path: Rails.root.join("config/credentials.yml.enc"),
|
||||
key_path: Rails.root.join("config/master.key"),
|
||||
env_key: "RAILS_MASTER_KEY"
|
||||
end
|
||||
|
||||
def to_app #:nodoc:
|
||||
self
|
||||
end
|
||||
@ -504,14 +527,13 @@ def default_middleware_stack #:nodoc:
|
||||
default_stack.build_stack
|
||||
end
|
||||
|
||||
def validate_secret_key_config! #:nodoc:
|
||||
if secrets.secret_key_base.blank?
|
||||
ActiveSupport::Deprecation.warn "You didn't set `secret_key_base`. " \
|
||||
"Read the upgrade documentation to learn more about this new config option."
|
||||
|
||||
if secrets.secret_token.blank?
|
||||
raise "Missing `secret_key_base` for '#{Rails.env}' environment, set this value in `config/secrets.yml`"
|
||||
end
|
||||
def validate_secret_key_base(secret_key_base)
|
||||
if secret_key_base.is_a?(String) && secret_key_base.present?
|
||||
secret_key_base
|
||||
elsif secret_key_base
|
||||
raise ArgumentError, "`secret_key_base` for #{Rails.env} environment must be a type of String`"
|
||||
elsif secrets.secret_token.blank?
|
||||
raise ArgumentError, "Missing `secret_key_base` for '#{Rails.env}' environment, set this string with `rails credentials:edit`"
|
||||
end
|
||||
end
|
||||
|
||||
|
40
railties/lib/rails/commands/credentials/USAGE
Normal file
40
railties/lib/rails/commands/credentials/USAGE
Normal file
@ -0,0 +1,40 @@
|
||||
=== Storing Encrypted Credentials in Source Control
|
||||
|
||||
The Rails `credentials` commands provide access to encrypted credentials,
|
||||
so you can safely store access tokens, database passwords, and the like
|
||||
safely inside the app without relying on a mess of ENVs.
|
||||
|
||||
This also allows for atomic deploys: no need to coordinate key changes
|
||||
to get everything working as the keys are shipped with the code.
|
||||
|
||||
=== Setup
|
||||
|
||||
Applications after Rails 5.2 automatically have a basic credentials file generated
|
||||
that just contains the secret_key_base used by the MessageVerifiers, like the one
|
||||
signing and encrypting cookies.
|
||||
|
||||
For applications created prior to Rails 5.2, we'll automatically generate a new
|
||||
credentials file in `config/credentials.yml.enc` the first time you run `bin/rails credentials:edit`.
|
||||
If you didn't have a master key saved in `config/master.key`, that'll be created too.
|
||||
|
||||
Don't lose this master key! Put it in a password manager your team can access.
|
||||
Should you lose it no one, including you, will be able to access any encrypted
|
||||
credentials.
|
||||
|
||||
Don't commit the key! Add `config/master.key` to your source control's
|
||||
ignore file. If you use Git, Rails handles this for you.
|
||||
|
||||
Rails also looks for the master key in `ENV["RAILS_MASTER_KEY"]`, if that's easier to manage.
|
||||
|
||||
You could prepend that to your server's start command like this:
|
||||
|
||||
RAILS_MASTER_KEY="very-secret-and-secure" server.start
|
||||
|
||||
=== Editing Credentials
|
||||
|
||||
This will open a temporary file in `$EDITOR` with the decrypted contents to edit
|
||||
the encrypted credentials.
|
||||
|
||||
When the temporary file is next saved the contents are encrypted and written to
|
||||
`config/credentials.yml.enc` while the file itself is destroyed to prevent credentials
|
||||
from leaking.
|
@ -0,0 +1,85 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "active_support"
|
||||
|
||||
module Rails
|
||||
module Command
|
||||
class CredentialsCommand < Rails::Command::Base # :nodoc:
|
||||
no_commands do
|
||||
def help
|
||||
say "Usage:\n #{self.class.banner}"
|
||||
say ""
|
||||
say self.class.desc
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
require_application_and_environment!
|
||||
|
||||
ensure_editor_available || (return)
|
||||
ensure_master_key_has_been_added
|
||||
ensure_credentials_have_been_added
|
||||
|
||||
change_credentials_in_system_editor
|
||||
|
||||
say "New credentials encrypted and saved."
|
||||
rescue Interrupt
|
||||
say "Aborted changing credentials: nothing saved."
|
||||
rescue ActiveSupport::EncryptedFile::MissingKeyError => error
|
||||
say error.message
|
||||
end
|
||||
|
||||
def show
|
||||
require_application_and_environment!
|
||||
say Rails.application.credentials.read.presence ||
|
||||
"No credentials have been added yet. Use bin/rails credentials:edit to change that."
|
||||
end
|
||||
|
||||
private
|
||||
def ensure_editor_available
|
||||
if ENV["EDITOR"].to_s.empty?
|
||||
say "No $EDITOR to open credentials in. Assign one like this:"
|
||||
say ""
|
||||
say %(EDITOR="mate --wait" bin/rails credentials:edit)
|
||||
say ""
|
||||
say "For editors that fork and exit immediately, it's important to pass a wait flag,"
|
||||
say "otherwise the credentials will be saved immediately with no chance to edit."
|
||||
|
||||
false
|
||||
else
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_master_key_has_been_added
|
||||
master_key_generator.add_master_key_file
|
||||
master_key_generator.ignore_master_key_file
|
||||
end
|
||||
|
||||
def ensure_credentials_have_been_added
|
||||
credentials_generator.add_credentials_file_silently
|
||||
end
|
||||
|
||||
def change_credentials_in_system_editor
|
||||
Rails.application.credentials.change do |tmp_path|
|
||||
system("#{ENV["EDITOR"]} #{tmp_path}")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
def master_key_generator
|
||||
require_relative "../../generators"
|
||||
require_relative "../../generators/rails/master_key/master_key_generator"
|
||||
|
||||
Rails::Generators::MasterKeyGenerator.new
|
||||
end
|
||||
|
||||
def credentials_generator
|
||||
require_relative "../../generators"
|
||||
require_relative "../../generators/rails/credentials/credentials_generator"
|
||||
|
||||
Rails::Generators::CredentialsGenerator.new
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -111,7 +111,6 @@ def config
|
||||
template "routes.rb"
|
||||
template "application.rb"
|
||||
template "environment.rb"
|
||||
template "secrets.yml"
|
||||
template "cable.yml" unless options[:skip_action_cable]
|
||||
template "puma.rb" unless options[:skip_puma]
|
||||
template "spring.rb" if spring_install?
|
||||
@ -159,6 +158,22 @@ def config_when_updating
|
||||
end
|
||||
end
|
||||
|
||||
def master_key
|
||||
require_relative "../master_key/master_key_generator"
|
||||
|
||||
after_bundle do
|
||||
Rails::Generators::MasterKeyGenerator.new.add_master_key_file
|
||||
end
|
||||
end
|
||||
|
||||
def credentials
|
||||
require_relative "../credentials/credentials_generator"
|
||||
|
||||
after_bundle do
|
||||
Rails::Generators::CredentialsGenerator.new.add_credentials_file_silently
|
||||
end
|
||||
end
|
||||
|
||||
def database_yml
|
||||
template "config/databases/#{options[:database]}.yml", "config/database.yml"
|
||||
end
|
||||
@ -289,6 +304,14 @@ def update_config_files
|
||||
end
|
||||
remove_task :update_config_files
|
||||
|
||||
def create_master_key
|
||||
build(:master_key)
|
||||
end
|
||||
|
||||
def create_credentials
|
||||
build(:credentials)
|
||||
end
|
||||
|
||||
def display_upgrade_guide_info
|
||||
say "\nAfter this, check Rails upgrade guide at http://guides.rubyonrails.org/upgrading_ruby_on_rails.html for more details about upgrading your app."
|
||||
end
|
||||
|
@ -14,10 +14,9 @@ Rails.application.configure do
|
||||
config.consider_all_requests_local = false
|
||||
config.action_controller.perform_caching = true
|
||||
|
||||
# Attempt to read encrypted secrets from `config/secrets.yml.enc`.
|
||||
# Requires an encryption key in `ENV["RAILS_MASTER_KEY"]` or
|
||||
# `config/secrets.yml.key`.
|
||||
config.read_encrypted_secrets = true
|
||||
# Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"]
|
||||
# or in config/master.key. This key is used to decrypt credentials (and other encrypted files).
|
||||
# config.require_master_key = true
|
||||
|
||||
# Disable serving static files from the `/public` folder by default since
|
||||
# Apache or NGINX already handles this.
|
||||
|
@ -7,6 +7,9 @@
|
||||
# Ignore bundler config.
|
||||
/.bundle
|
||||
|
||||
# Ignore master key for decrypting credentials and more.
|
||||
/config/master.key
|
||||
|
||||
<% if sqlite3? -%>
|
||||
# Ignore the default SQLite database.
|
||||
/db/*.sqlite3
|
||||
|
@ -0,0 +1,46 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../../base"
|
||||
require_relative "../master_key/master_key_generator"
|
||||
require "active_support/encrypted_configuration"
|
||||
|
||||
module Rails
|
||||
module Generators
|
||||
class CredentialsGenerator < Base
|
||||
CONFIG_PATH = "config/credentials.yml.enc"
|
||||
KEY_PATH = "config/master.key"
|
||||
|
||||
def add_credentials_file
|
||||
unless File.exist?(CONFIG_PATH)
|
||||
template = credentials_template
|
||||
|
||||
say "Adding #{CONFIG_PATH} to store encrypted credentials."
|
||||
say ""
|
||||
say "The following content has been encrypted with the Rails master key:"
|
||||
say ""
|
||||
say template, :on_green
|
||||
say ""
|
||||
|
||||
add_credentials_file_silently(template)
|
||||
|
||||
say "You can edit encrypted credentials with `bin/rails credentials:edit`."
|
||||
say ""
|
||||
end
|
||||
end
|
||||
|
||||
def add_credentials_file_silently(template = nil)
|
||||
unless File.exist?(CONFIG_PATH)
|
||||
setup = { config_path: CONFIG_PATH, key_path: KEY_PATH, env_key: "RAILS_MASTER_KEY" }
|
||||
ActiveSupport::EncryptedConfiguration.new(setup).write(credentials_template)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def credentials_template
|
||||
"# amazon:\n# access_key_id: 123\n# secret_access_key: 345\n\n" +
|
||||
"# Used as the base secret for all MessageVerifiers in Rails, including the one protecting cookies.\n" +
|
||||
"secret_key_base: #{SecureRandom.hex(64)}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../../base"
|
||||
require "pathname"
|
||||
require "active_support/encrypted_file"
|
||||
|
||||
module Rails
|
||||
module Generators
|
||||
class MasterKeyGenerator < Base
|
||||
MASTER_KEY_PATH = Pathname.new("config/master.key")
|
||||
|
||||
def add_master_key_file
|
||||
unless MASTER_KEY_PATH.exist?
|
||||
key = ActiveSupport::EncryptedFile.generate_key
|
||||
|
||||
say "Adding #{MASTER_KEY_PATH} to store the master encryption key: #{key}"
|
||||
say ""
|
||||
say "Save this in a password manager your team can access."
|
||||
say ""
|
||||
say "If you lose the key, no one, including you, can access anything encrypted with it."
|
||||
|
||||
say ""
|
||||
add_master_key_file_silently key
|
||||
say ""
|
||||
end
|
||||
end
|
||||
|
||||
def add_master_key_file_silently(key = nil)
|
||||
create_file MASTER_KEY_PATH, key || ActiveSupport::EncryptedFile.generate_key
|
||||
end
|
||||
|
||||
def ignore_master_key_file
|
||||
if File.exist?(".gitignore")
|
||||
unless File.read(".gitignore").include?(key_ignore)
|
||||
say "Ignoring #{MASTER_KEY_PATH} so it won't end up in Git history:"
|
||||
say ""
|
||||
append_to_file ".gitignore", key_ignore
|
||||
say ""
|
||||
end
|
||||
else
|
||||
say "IMPORTANT: Don't commit #{MASTER_KEY_PATH}. Add this to your ignore file:"
|
||||
say key_ignore, :on_green
|
||||
say ""
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def key_ignore
|
||||
[ "", "# Ignore master key for decrypting credentials and more.", MASTER_KEY_PATH, "" ].join("\n")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -15,7 +15,6 @@
|
||||
module TestApp
|
||||
class Application < Rails::Application
|
||||
config.root = __dir__
|
||||
secrets.secret_key_base = "b3c631c314c0bbca50c1b2843150fe33"
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -476,45 +476,35 @@ def index
|
||||
|
||||
test "application message verifier can be used when the key_generator is ActiveSupport::LegacyKeyGenerator" do
|
||||
app_file "config/initializers/secret_token.rb", <<-RUBY
|
||||
Rails.application.credentials.secret_key_base = nil
|
||||
Rails.application.config.secret_token = "b3c631c314c0bbca50c1b2843150fe33"
|
||||
RUBY
|
||||
app_file "config/secrets.yml", <<-YAML
|
||||
development:
|
||||
secret_key_base:
|
||||
YAML
|
||||
|
||||
app "development"
|
||||
app "production"
|
||||
|
||||
assert_equal app.env_config["action_dispatch.key_generator"], Rails.application.key_generator
|
||||
assert_equal app.env_config["action_dispatch.key_generator"].class, ActiveSupport::LegacyKeyGenerator
|
||||
assert_kind_of ActiveSupport::LegacyKeyGenerator, Rails.application.key_generator
|
||||
message = app.message_verifier(:sensitive_value).generate("some_value")
|
||||
assert_equal "some_value", Rails.application.message_verifier(:sensitive_value).verify(message)
|
||||
end
|
||||
|
||||
test "warns when secrets.secret_key_base is blank and config.secret_token is set" do
|
||||
test "raises when secret_key_base is blank" do
|
||||
app_file "config/initializers/secret_token.rb", <<-RUBY
|
||||
Rails.application.config.secret_token = "b3c631c314c0bbca50c1b2843150fe33"
|
||||
Rails.application.credentials.secret_key_base = nil
|
||||
RUBY
|
||||
app_file "config/secrets.yml", <<-YAML
|
||||
development:
|
||||
secret_key_base:
|
||||
YAML
|
||||
|
||||
app "development"
|
||||
|
||||
assert_deprecated(/You didn't set `secret_key_base`./) do
|
||||
app.env_config
|
||||
error = assert_raise(ArgumentError) do
|
||||
app "production"
|
||||
end
|
||||
assert_match(/Missing `secret_key_base`./, error.message)
|
||||
end
|
||||
|
||||
test "raise when secrets.secret_key_base is not a type of string" do
|
||||
app_file "config/secrets.yml", <<-YAML
|
||||
development:
|
||||
secret_key_base: 123
|
||||
YAML
|
||||
test "raise when secret_key_base is not a type of string" do
|
||||
add_to_config <<-RUBY
|
||||
Rails.application.credentials.secret_key_base = 123
|
||||
RUBY
|
||||
|
||||
assert_raise(ArgumentError) do
|
||||
app "development"
|
||||
app "production"
|
||||
end
|
||||
end
|
||||
|
||||
@ -534,7 +524,7 @@ def index
|
||||
|
||||
test "application verifier can build different verifiers" do
|
||||
make_basic_app do |application|
|
||||
application.secrets.secret_key_base = "b3c631c314c0bbca50c1b2843150fe33"
|
||||
application.credentials.secret_key_base = "b3c631c314c0bbca50c1b2843150fe33"
|
||||
application.config.session_store :disabled
|
||||
end
|
||||
|
||||
@ -652,37 +642,15 @@ def index
|
||||
|
||||
test "uses ActiveSupport::LegacyKeyGenerator as app.key_generator when secrets.secret_key_base is blank" do
|
||||
app_file "config/initializers/secret_token.rb", <<-RUBY
|
||||
Rails.application.credentials.secret_key_base = nil
|
||||
Rails.application.config.secret_token = "b3c631c314c0bbca50c1b2843150fe33"
|
||||
RUBY
|
||||
app_file "config/secrets.yml", <<-YAML
|
||||
development:
|
||||
secret_key_base:
|
||||
YAML
|
||||
|
||||
app "development"
|
||||
app "production"
|
||||
|
||||
assert_equal "b3c631c314c0bbca50c1b2843150fe33", app.config.secret_token
|
||||
assert_nil app.secrets.secret_key_base
|
||||
assert_equal app.key_generator.class, ActiveSupport::LegacyKeyGenerator
|
||||
end
|
||||
|
||||
test "uses ActiveSupport::LegacyKeyGenerator with config.secret_token as app.key_generator when secrets.secret_key_base is blank" do
|
||||
app_file "config/initializers/secret_token.rb", <<-RUBY
|
||||
Rails.application.config.secret_token = ""
|
||||
RUBY
|
||||
app_file "config/secrets.yml", <<-YAML
|
||||
development:
|
||||
secret_key_base:
|
||||
YAML
|
||||
|
||||
app "development"
|
||||
|
||||
assert_equal "", app.config.secret_token
|
||||
assert_nil app.secrets.secret_key_base
|
||||
e = assert_raise ArgumentError do
|
||||
app.key_generator
|
||||
end
|
||||
assert_match(/\AA secret is required/, e.message)
|
||||
assert_nil app.credentials.secret_key_base
|
||||
assert_kind_of ActiveSupport::LegacyKeyGenerator, app.key_generator
|
||||
end
|
||||
|
||||
test "that nested keys are symbolized the same as parents for hashes more than one level deep" do
|
||||
@ -699,6 +667,20 @@ def index
|
||||
assert_equal "697361616320736c6f616e2028656c6f7265737429", app.secrets.smtp_settings[:password]
|
||||
end
|
||||
|
||||
test "require_master_key aborts app boot when missing key" do
|
||||
skip "can't run without fork" unless Process.respond_to?(:fork)
|
||||
|
||||
remove_file "config/master.key"
|
||||
add_to_config "config.require_master_key = true"
|
||||
|
||||
error = capture(:stderr) do
|
||||
Process.wait(Process.fork { app "development" })
|
||||
end
|
||||
|
||||
assert_equal 1, $?.exitstatus
|
||||
assert_match(/Missing.*RAILS_MASTER_KEY/, error)
|
||||
end
|
||||
|
||||
test "protect from forgery is the default in a new app" do
|
||||
make_basic_app
|
||||
|
||||
|
@ -15,10 +15,6 @@ def teardown
|
||||
teardown_app
|
||||
end
|
||||
|
||||
def app
|
||||
@app ||= Rails.application
|
||||
end
|
||||
|
||||
define_method :simple_controller do
|
||||
class ::OmgController < ActionController::Base
|
||||
def index
|
||||
|
@ -337,12 +337,15 @@ def read_raw_cookie
|
||||
|
||||
add_to_config <<-RUBY
|
||||
# Use a static key
|
||||
secrets.secret_key_base = "known key base"
|
||||
Rails.application.credentials.secret_key_base = "known key base"
|
||||
|
||||
# Enable AEAD cookies
|
||||
config.action_dispatch.use_authenticated_cookie_encryption = true
|
||||
RUBY
|
||||
|
||||
begin
|
||||
old_rails_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "production"
|
||||
|
||||
require "#{app_path}/config/environment"
|
||||
|
||||
get "/foo/write_raw_session"
|
||||
@ -362,6 +365,9 @@ def read_raw_cookie
|
||||
|
||||
get "/foo/read_raw_cookie"
|
||||
assert_equal 2, encryptor.decrypt_and_verify(last_response.body)["foo"]
|
||||
ensure
|
||||
ENV["RAILS_ENV"] = old_rails_env
|
||||
end
|
||||
end
|
||||
|
||||
test "session upgrading legacy signed cookies to new signed cookies" do
|
||||
@ -400,9 +406,12 @@ def read_raw_cookie
|
||||
|
||||
add_to_config <<-RUBY
|
||||
secrets.secret_token = "3b7cd727ee24e8444053437c36cc66c4"
|
||||
secrets.secret_key_base = nil
|
||||
Rails.application.credentials.secret_key_base = nil
|
||||
RUBY
|
||||
|
||||
begin
|
||||
old_rails_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "production"
|
||||
|
||||
require "#{app_path}/config/environment"
|
||||
|
||||
get "/foo/write_raw_session"
|
||||
@ -420,6 +429,9 @@ def read_raw_cookie
|
||||
|
||||
get "/foo/read_raw_cookie"
|
||||
assert_equal 2, verifier.verify(last_response.body)["foo"]
|
||||
ensure
|
||||
ENV["RAILS_ENV"] = old_rails_env
|
||||
end
|
||||
end
|
||||
|
||||
test "calling reset_session on request does not trigger an error for API apps" do
|
||||
|
@ -16,7 +16,6 @@ def app
|
||||
require "action_view/railtie"
|
||||
|
||||
class MyApp < Rails::Application
|
||||
secrets.secret_key_base = "3b7cd727ee24e8444053437c36cc66c4"
|
||||
config.session_store :cookie_store, key: "_myapp_session"
|
||||
config.active_support.deprecation = :log
|
||||
config.eager_load = false
|
||||
|
@ -125,7 +125,7 @@ def default_files
|
||||
config/locales/en.yml
|
||||
config/puma.rb
|
||||
config/routes.rb
|
||||
config/secrets.yml
|
||||
config/credentials.yml.enc
|
||||
config/spring.rb
|
||||
config/storage.yml
|
||||
db
|
||||
|
@ -64,7 +64,7 @@
|
||||
config/locales/en.yml
|
||||
config/puma.rb
|
||||
config/routes.rb
|
||||
config/secrets.yml
|
||||
config/credentials.yml.enc
|
||||
config/spring.rb
|
||||
config/storage.yml
|
||||
db
|
||||
@ -287,8 +287,6 @@ def test_app_update_does_not_generate_action_cable_contents_when_skip_action_cab
|
||||
run_generator [app_root, "--skip-action-cable"]
|
||||
|
||||
FileUtils.cd(app_root) do
|
||||
# For avoid conflict file
|
||||
FileUtils.rm("#{app_root}/config/secrets.yml")
|
||||
quietly { system("bin/rails app:update") }
|
||||
end
|
||||
|
||||
|
@ -149,7 +149,7 @@ def test_generator_without_skips
|
||||
end
|
||||
assert_file "#{application_path}/config/environments/production.rb" do |content|
|
||||
assert_match(/# config\.action_mailer\.raise_delivery_errors = false/, content)
|
||||
assert_match(/^ config\.read_encrypted_secrets = true/, content)
|
||||
assert_match(/^ # config\.require_master_key = true/, content)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -105,7 +105,6 @@ module Generation
|
||||
def build_app(options = {})
|
||||
@prev_rails_env = ENV["RAILS_ENV"]
|
||||
ENV["RAILS_ENV"] = "development"
|
||||
ENV["SECRET_KEY_BASE"] ||= SecureRandom.hex(16)
|
||||
|
||||
FileUtils.rm_rf(app_path)
|
||||
FileUtils.cp_r(app_template_path, app_path)
|
||||
@ -163,9 +162,10 @@ def make_basic_app
|
||||
require "action_controller/railtie"
|
||||
require "action_view/railtie"
|
||||
|
||||
@app = Class.new(Rails::Application)
|
||||
@app = Class.new(Rails::Application) do
|
||||
def self.name; "RailtiesTestApp"; end
|
||||
end
|
||||
@app.config.eager_load = false
|
||||
@app.secrets.secret_key_base = "3b7cd727ee24e8444053437c36cc66c4"
|
||||
@app.config.session_store :cookie_store, key: "_myapp_session"
|
||||
@app.config.active_support.deprecation = :log
|
||||
@app.config.active_support.test_order = :random
|
||||
|
@ -58,12 +58,14 @@ def test_original_script_name
|
||||
Rails.logger = Logger.new nil
|
||||
|
||||
app = Class.new(Rails::Application) {
|
||||
def self.name; "ScriptNameTestApp"; end
|
||||
|
||||
attr_accessor :controller
|
||||
|
||||
def initialize
|
||||
super
|
||||
app = self
|
||||
@routes = TestSet.new ->(c) { app.controller = c }
|
||||
secrets.secret_key_base = "foo"
|
||||
secrets.secret_token = "foo"
|
||||
end
|
||||
def app; routes; end
|
||||
|
@ -176,6 +176,10 @@ def run_secrets_generator
|
||||
Rails::Generators::EncryptedSecretsGenerator.start
|
||||
end
|
||||
|
||||
add_to_config <<-RUBY
|
||||
config.read_encrypted_secrets = true
|
||||
RUBY
|
||||
|
||||
yield
|
||||
end
|
||||
end
|
||||
|
Loading…
Reference in New Issue
Block a user