diff options
author | David Heinemeier Hansson <david@loudthinking.com> | 2017-09-11 13:21:20 -0500 |
---|---|---|
committer | Kasper Timm Hansen <kaspth@gmail.com> | 2017-09-11 20:21:20 +0200 |
commit | 69f976b859cae7f9d050152103da018b7f5dda6d (patch) | |
tree | fdb2437de4931d5362763f730dc28fa53e147b11 | |
parent | 80573a099e9974173a2f6d9a1ca81c7cc53ed3f4 (diff) | |
download | rails-69f976b859cae7f9d050152103da018b7f5dda6d.tar.gz rails-69f976b859cae7f9d050152103da018b7f5dda6d.tar.bz2 rails-69f976b859cae7f9d050152103da018b7f5dda6d.zip |
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.
28 files changed, 678 insertions, 123 deletions
diff --git a/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb b/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb index a12cb00d36..f594b6f491 100644 --- a/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb +++ b/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb @@ -33,12 +33,12 @@ module ActionDispatch # # 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. diff --git a/activestorage/lib/active_storage/engine.rb b/activestorage/lib/active_storage/engine.rb index a5562b32d3..590a36a30a 100644 --- a/activestorage/lib/active_storage/engine.rb +++ b/activestorage/lib/active_storage/engine.rb @@ -29,9 +29,7 @@ module ActiveStorage initializer "active_storage.verifier" do config.after_initialize do |app| - if app.secrets.secret_key_base.present? - ActiveStorage.verifier = app.message_verifier("ActiveStorage") - end + ActiveStorage.verifier = app.message_verifier("ActiveStorage") end end diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index ac1043df78..f158d5357d 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -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`. diff --git a/activesupport/lib/active_support/encrypted_configuration.rb b/activesupport/lib/active_support/encrypted_configuration.rb new file mode 100644 index 0000000000..b403048627 --- /dev/null +++ b/activesupport/lib/active_support/encrypted_configuration.rb @@ -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 diff --git a/activesupport/lib/active_support/encrypted_file.rb b/activesupport/lib/active_support/encrypted_file.rb new file mode 100644 index 0000000000..1c4c1cb457 --- /dev/null +++ b/activesupport/lib/active_support/encrypted_file.rb @@ -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 diff --git a/activesupport/lib/active_support/railtie.rb b/activesupport/lib/active_support/railtie.rb index 2efe391d01..fe132ca7c6 100644 --- a/activesupport/lib/active_support/railtie.rb +++ b/activesupport/lib/active_support/railtie.rb @@ -49,6 +49,17 @@ module ActiveSupport 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}=" diff --git a/activesupport/test/encrypted_configuration_test.rb b/activesupport/test/encrypted_configuration_test.rb new file mode 100644 index 0000000000..53ea9e393f --- /dev/null +++ b/activesupport/test/encrypted_configuration_test.rb @@ -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 diff --git a/activesupport/test/encrypted_file_test.rb b/activesupport/test/encrypted_file_test.rb new file mode 100644 index 0000000000..7259726d08 --- /dev/null +++ b/activesupport/test/encrypted_file_test.rb @@ -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 diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index e61adda7c3..5057059898 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -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* diff --git a/railties/lib/rails/application.rb b/railties/lib/rails/application.rb index 72f8bf0e14..6ce8b0b2d9 100644 --- a/railties/lib/rails/application.rb +++ b/railties/lib/rails/application.rb @@ -5,6 +5,7 @@ require "active_support/core_ext/hash/keys" 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 @@ module Rails # 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 @@ module Rails # 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 @@ module Rails @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 @@ module Rails 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 diff --git a/railties/lib/rails/commands/credentials/USAGE b/railties/lib/rails/commands/credentials/USAGE new file mode 100644 index 0000000000..5bd9f940fd --- /dev/null +++ b/railties/lib/rails/commands/credentials/USAGE @@ -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. diff --git a/railties/lib/rails/commands/credentials/credentials_command.rb b/railties/lib/rails/commands/credentials/credentials_command.rb new file mode 100644 index 0000000000..39a4e3c833 --- /dev/null +++ b/railties/lib/rails/commands/credentials/credentials_command.rb @@ -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 diff --git a/railties/lib/rails/generators/rails/app/app_generator.rb b/railties/lib/rails/generators/rails/app/app_generator.rb index 0f73cc4755..c67baa5e91 100644 --- a/railties/lib/rails/generators/rails/app/app_generator.rb +++ b/railties/lib/rails/generators/rails/app/app_generator.rb @@ -111,7 +111,6 @@ module Rails 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 @@ module Rails 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 @@ module Rails 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 diff --git a/railties/lib/rails/generators/rails/app/templates/config/environments/production.rb.tt b/railties/lib/rails/generators/rails/app/templates/config/environments/production.rb.tt index f68e13aa8b..2e0b555f6f 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/environments/production.rb.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/environments/production.rb.tt @@ -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. diff --git a/railties/lib/rails/generators/rails/app/templates/gitignore b/railties/lib/rails/generators/rails/app/templates/gitignore index 83a7b211aa..c37f01a848 100644 --- a/railties/lib/rails/generators/rails/app/templates/gitignore +++ b/railties/lib/rails/generators/rails/app/templates/gitignore @@ -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 diff --git a/railties/lib/rails/generators/rails/credentials/credentials_generator.rb b/railties/lib/rails/generators/rails/credentials/credentials_generator.rb new file mode 100644 index 0000000000..ddcccd5ce5 --- /dev/null +++ b/railties/lib/rails/generators/rails/credentials/credentials_generator.rb @@ -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 diff --git a/railties/lib/rails/generators/rails/master_key/master_key_generator.rb b/railties/lib/rails/generators/rails/master_key/master_key_generator.rb new file mode 100644 index 0000000000..36a0b69e76 --- /dev/null +++ b/railties/lib/rails/generators/rails/master_key/master_key_generator.rb @@ -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 diff --git a/railties/test/abstract_unit.rb b/railties/test/abstract_unit.rb index a63b7a8377..b42f37d6b9 100644 --- a/railties/test/abstract_unit.rb +++ b/railties/test/abstract_unit.rb @@ -15,7 +15,6 @@ require "rails/all" module TestApp class Application < Rails::Application config.root = __dir__ - secrets.secret_key_base = "b3c631c314c0bbca50c1b2843150fe33" end end diff --git a/railties/test/application/configuration_test.rb b/railties/test/application/configuration_test.rb index 64939e4ab4..c1a80eaeaf 100644 --- a/railties/test/application/configuration_test.rb +++ b/railties/test/application/configuration_test.rb @@ -476,45 +476,35 @@ module ApplicationTests 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 @@ module ApplicationTests 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 @@ module ApplicationTests 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 @@ module ApplicationTests 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 diff --git a/railties/test/application/middleware/sendfile_test.rb b/railties/test/application/middleware/sendfile_test.rb index 4731396029..9def3a0ce7 100644 --- a/railties/test/application/middleware/sendfile_test.rb +++ b/railties/test/application/middleware/sendfile_test.rb @@ -15,10 +15,6 @@ module ApplicationTests teardown_app end - def app - @app ||= Rails.application - end - define_method :simple_controller do class ::OmgController < ActionController::Base def index diff --git a/railties/test/application/middleware/session_test.rb b/railties/test/application/middleware/session_test.rb index 15acfe93e9..a17988235a 100644 --- a/railties/test/application/middleware/session_test.rb +++ b/railties/test/application/middleware/session_test.rb @@ -337,31 +337,37 @@ module ApplicationTests 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 - require "#{app_path}/config/environment" + begin + old_rails_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "production" - get "/foo/write_raw_session" - get "/foo/read_session" - assert_equal "1", last_response.body + require "#{app_path}/config/environment" - get "/foo/write_session" - get "/foo/read_session" - assert_equal "2", last_response.body + get "/foo/write_raw_session" + get "/foo/read_session" + assert_equal "1", last_response.body - get "/foo/read_encrypted_cookie" - assert_equal "2", last_response.body + get "/foo/write_session" + get "/foo/read_session" + assert_equal "2", last_response.body - cipher = "aes-256-gcm" - secret = app.key_generator.generate_key("authenticated encrypted cookie") - encryptor = ActiveSupport::MessageEncryptor.new(secret[0, ActiveSupport::MessageEncryptor.key_len(cipher)], cipher: cipher) + get "/foo/read_encrypted_cookie" + assert_equal "2", last_response.body - get "/foo/read_raw_cookie" - assert_equal 2, encryptor.decrypt_and_verify(last_response.body)["foo"] + cipher = "aes-256-gcm" + secret = app.key_generator.generate_key("authenticated encrypted cookie") + encryptor = ActiveSupport::MessageEncryptor.new(secret[0, ActiveSupport::MessageEncryptor.key_len(cipher)], cipher: cipher) + + 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,26 +406,32 @@ module ApplicationTests add_to_config <<-RUBY secrets.secret_token = "3b7cd727ee24e8444053437c36cc66c4" - secrets.secret_key_base = nil + Rails.application.credentials.secret_key_base = nil RUBY - require "#{app_path}/config/environment" + begin + old_rails_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "production" - get "/foo/write_raw_session" - get "/foo/read_session" - assert_equal "1", last_response.body + require "#{app_path}/config/environment" - get "/foo/write_session" - get "/foo/read_session" - assert_equal "2", last_response.body + get "/foo/write_raw_session" + get "/foo/read_session" + assert_equal "1", last_response.body - get "/foo/read_signed_cookie" - assert_equal "2", last_response.body + get "/foo/write_session" + get "/foo/read_session" + assert_equal "2", last_response.body - verifier = ActiveSupport::MessageVerifier.new(app.secrets.secret_token) + get "/foo/read_signed_cookie" + assert_equal "2", last_response.body - get "/foo/read_raw_cookie" - assert_equal 2, verifier.verify(last_response.body)["foo"] + verifier = ActiveSupport::MessageVerifier.new(app.secrets.secret_token) + + 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 diff --git a/railties/test/application/url_generation_test.rb b/railties/test/application/url_generation_test.rb index 4f962db6c4..f22b9fda3d 100644 --- a/railties/test/application/url_generation_test.rb +++ b/railties/test/application/url_generation_test.rb @@ -16,7 +16,6 @@ module ApplicationTests 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 diff --git a/railties/test/generators/api_app_generator_test.rb b/railties/test/generators/api_app_generator_test.rb index d141b1d4b4..7791d472d8 100644 --- a/railties/test/generators/api_app_generator_test.rb +++ b/railties/test/generators/api_app_generator_test.rb @@ -125,7 +125,7 @@ class ApiAppGeneratorTest < Rails::Generators::TestCase config/locales/en.yml config/puma.rb config/routes.rb - config/secrets.yml + config/credentials.yml.enc config/spring.rb config/storage.yml db diff --git a/railties/test/generators/app_generator_test.rb b/railties/test/generators/app_generator_test.rb index f64ebf5f1f..904e2a5c84 100644 --- a/railties/test/generators/app_generator_test.rb +++ b/railties/test/generators/app_generator_test.rb @@ -64,7 +64,7 @@ DEFAULT_APP_FILES = %w( 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 @@ class AppGeneratorTest < Rails::Generators::TestCase 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 diff --git a/railties/test/generators/shared_generator_tests.rb b/railties/test/generators/shared_generator_tests.rb index 56c9b37e1b..654d16de65 100644 --- a/railties/test/generators/shared_generator_tests.rb +++ b/railties/test/generators/shared_generator_tests.rb @@ -149,7 +149,7 @@ module SharedGeneratorTests 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 diff --git a/railties/test/isolation/abstract_unit.rb b/railties/test/isolation/abstract_unit.rb index b590dac4fb..b7f214cb73 100644 --- a/railties/test/isolation/abstract_unit.rb +++ b/railties/test/isolation/abstract_unit.rb @@ -105,7 +105,6 @@ module TestHelpers 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 @@ module TestHelpers 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 diff --git a/railties/test/path_generation_test.rb b/railties/test/path_generation_test.rb index d4dfa8e4a6..849b183b37 100644 --- a/railties/test/path_generation_test.rb +++ b/railties/test/path_generation_test.rb @@ -58,12 +58,14 @@ class PathGenerationTest < ActiveSupport::TestCase 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 diff --git a/railties/test/secrets_test.rb b/railties/test/secrets_test.rb index a394f5661e..888fee173a 100644 --- a/railties/test/secrets_test.rb +++ b/railties/test/secrets_test.rb @@ -176,6 +176,10 @@ class Rails::SecretsTest < ActiveSupport::TestCase Rails::Generators::EncryptedSecretsGenerator.start end + add_to_config <<-RUBY + config.read_encrypted_secrets = true + RUBY + yield end end |