1
0
Fork 0
mirror of https://github.com/rails/rails.git synced 2022-11-09 12:12:34 -05:00

Revert "Revert "Add encrypted secrets""

This commit is contained in:
Kasper Timm Hansen 2017-02-23 18:15:28 +01:00 committed by GitHub
parent 4734d23c74
commit fbee4e3ce3
17 changed files with 482 additions and 27 deletions

View file

@ -4,6 +4,7 @@ require "active_support/core_ext/object/blank"
require "active_support/key_generator"
require "active_support/message_verifier"
require "rails/engine"
require "rails/secrets"
module Rails
# An Engine with the responsibility of coordinating the whole boot process.
@ -385,18 +386,7 @@ module Rails
def secrets
@secrets ||= begin
secrets = ActiveSupport::OrderedOptions.new
yaml = config.paths["config/secrets"].first
if File.exist?(yaml)
require "erb"
all_secrets = YAML.load(ERB.new(IO.read(yaml)).result) || {}
shared_secrets = all_secrets["shared"]
env_secrets = all_secrets[Rails.env]
secrets.merge!(shared_secrets.deep_symbolize_keys) if shared_secrets
secrets.merge!(env_secrets.deep_symbolize_keys) if env_secrets
end
secrets.merge! Rails::Secrets.parse(config.paths["config/secrets"].existent, env: Rails.env)
# Fallback to config.secret_key_base if secrets.secret_key_base isn't set
secrets.secret_key_base ||= config.secret_key_base

View file

@ -2,6 +2,7 @@ require "fileutils"
require "active_support/notifications"
require "active_support/dependencies"
require "active_support/descendants_tracker"
require "rails/secrets"
module Rails
class Application
@ -77,6 +78,11 @@ INFO
initializer :bootstrap_hook, group: :all do |app|
ActiveSupport.run_load_hooks(:before_initialize, app)
end
initializer :set_secrets_root, group: :all do
Rails::Secrets.root = root
Rails::Secrets.read_encrypted_secrets = config.read_encrypted_secrets
end
end
end
end

View file

@ -13,7 +13,8 @@ module Rails
:railties_order, :relative_url_root, :secret_key_base, :secret_token,
:ssl_options, :public_file_server,
:session_options, :time_zone, :reload_classes_only_on_change,
:beginning_of_week, :filter_redirect, :x, :enable_dependency_loading
:beginning_of_week, :filter_redirect, :x, :enable_dependency_loading,
:read_encrypted_secrets
attr_writer :log_level
attr_reader :encoding, :api_only
@ -51,6 +52,7 @@ module Rails
@debug_exception_response_format = nil
@x = Custom.new
@enable_dependency_loading = false
@read_encrypted_secrets = false
end
def encoding=(value)
@ -80,7 +82,7 @@ module Rails
@paths ||= begin
paths = super
paths.add "config/database", with: "config/database.yml"
paths.add "config/secrets", with: "config/secrets.yml"
paths.add "config/secrets", with: "config", glob: "secrets.yml{,.enc}"
paths.add "config/environment", with: "config/environment.rb"
paths.add "lib/templates"
paths.add "log", with: "log/#{Rails.env}.log"

View file

@ -27,15 +27,22 @@ module Rails
end
# Receives a namespace, arguments and the behavior to invoke the command.
def invoke(namespace, args = [], **config)
namespace = namespace.to_s
namespace = "help" if namespace.blank? || HELP_MAPPINGS.include?(namespace)
namespace = "version" if %w( -v --version ).include? namespace
def invoke(full_namespace, args = [], **config)
namespace = full_namespace = full_namespace.to_s
if command = find_by_namespace(namespace)
command.perform(namespace, args, config)
if char = namespace =~ /:(\w+)$/
command_name, namespace = $1, namespace.slice(0, char)
else
find_by_namespace("rake").perform(namespace, args, config)
command_name = namespace
end
command_name = "help" if command_name.blank? || HELP_MAPPINGS.include?(command_name)
namespace = "version" if %w( -v --version ).include?(command_name)
if command = find_by_namespace(namespace, command_name)
command.perform(command_name, args, config)
else
find_by_namespace("rake").perform(full_namespace, args, config)
end
end
@ -52,8 +59,10 @@ module Rails
#
# Notice that "rails:commands:webrat" could be loaded as well, what
# Rails looks for is the first and last parts of the namespace.
def find_by_namespace(name) # :nodoc:
lookups = [ name, "rails:#{name}" ]
def find_by_namespace(namespace, command_name = nil) # :nodoc:
lookups = [ namespace ]
lookups << "#{namespace}:#{command_name}" if command_name
lookups.concat lookups.map { |lookup| "rails:#{lookup}" }
lookup(lookups)

View file

@ -56,7 +56,9 @@ module Rails
end
def perform(command, args, config) # :nodoc:
command = nil if Rails::Command::HELP_MAPPINGS.include?(args.first)
if Rails::Command::HELP_MAPPINGS.include?(args.first)
command, args = "help", []
end
dispatch(command, args.dup, nil, config)
end
@ -111,7 +113,7 @@ module Rails
# For a `Rails::Command::TestCommand` placed in `rails/command/test_command.rb`
# would return `rails/test`.
def default_command_root
path = File.expand_path(File.join("../commands", command_name), __dir__)
path = File.expand_path(File.join("../commands", command_root_namespace), __dir__)
path if File.exist?(path)
end
@ -129,6 +131,10 @@ module Rails
super
end
end
def command_root_namespace
(namespace.split(":") - %w( rails )).first
end
end
def help

View file

@ -0,0 +1,52 @@
=== Storing Encrypted Secrets in Source Control
The Rails `secrets` commands helps encrypting secrets to slim a production
environment's `ENV` hash. It's also useful for atomic deploys: no need to
coordinate key changes to get everything working as the keys are shipped
with the code.
=== Setup
Run `bin/rails secrets:setup` to opt in and generate the `config/secrets.yml.key`
and `config/secrets.yml.enc` files.
The latter contains all the keys to be encrypted while the former holds the
encryption key.
Don't lose the 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
secrets.
Don't commit the key! Add `config/secrets.yml.key` to your source control's
ignore file. If you use Git, Rails handles this for you.
Rails also looks for the 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="im-the-master-now-hahaha" server.start
The `config/secrets.yml.enc` has much the same format as `config/secrets.yml`:
production:
secret_key_base: so-secret-very-hidden-wow
payment_processing_gateway_key: much-safe-very-gaedwey-wow
But that's where the similarities between `secrets.yml` and `secrets.yml.enc`
end, e.g. no keys from `secrets.yml` will be moved to `secrets.yml.enc` and
be encrypted.
A `shared:` top level key is also supported such that any keys there is merged
into the other environments.
=== Editing Secrets
After `bin/rails secrets:setup`, run `bin/rails secrets:edit`.
That command opens a temporary file in `$EDITOR` with the decrypted contents of
`config/secrets.yml.enc` to edit the encrypted secrets.
When the temporary file is next saved the contents are encrypted and written to
`config/secrets.yml.enc` while the file itself is destroyed to prevent secrets
from leaking.

View file

@ -0,0 +1,50 @@
require "active_support"
require "rails/secrets"
module Rails
module Command
class SecretsCommand < Rails::Command::Base # :nodoc:
def help
say "Usage:\n #{self.class.banner}"
say ""
say self.class.desc
end
def setup
require "rails/generators"
require "rails/generators/rails/encrypted_secrets/encrypted_secrets_generator"
Rails::Generators::EncryptedSecretsGenerator.start
end
def edit
require_application_and_environment!
Rails::Secrets.read_for_editing do |tmp_path|
watch tmp_path do
puts "Waiting for secrets file to be saved. Abort with Ctrl-C."
system("\$EDITOR #{tmp_path}")
end
end
puts "New secrets encrypted and saved."
rescue Interrupt
puts "Aborted changing encrypted secrets: nothing saved."
rescue Rails::Secrets::MissingKeyError => error
say error.message
end
private
def watch(tmp_path)
mtime, start_time = File.mtime(tmp_path), Time.now
yield
editor_exits_after_open = $?.success? && (Time.now - start_time) < 1
if editor_exits_after_open
sleep 0.250 until File.mtime(tmp_path) != mtime
end
end
end
end
end

View file

@ -214,6 +214,7 @@ module Rails
rails.map! { |n| n.sub(/^rails:/, "") }
rails.delete("app")
rails.delete("plugin")
rails.delete("encrypted_secrets")
hidden_namespaces.each { |n| groups.delete(n.to_s) }

View file

@ -14,6 +14,11 @@ 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
# Disable serving static files from the `/public` folder by default since
# Apache or NGINX already handles this.
config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?

View file

@ -23,8 +23,10 @@ development:
test:
secret_key_base: <%= app_secret %>
# Do not keep production secrets in the repository,
# instead read values from the environment.
# Do not keep production secrets in the unencrypted secrets file.
# Instead, either read values from the environment.
# Or, use `bin/rails secrets:setup` to configure encrypted secrets
# and move the `production:` environment over there.
production:
secret_key_base: <%%= ENV["SECRET_KEY_BASE"] %>

View file

@ -0,0 +1,66 @@
require "rails/generators/base"
require "rails/secrets"
module Rails
module Generators
class EncryptedSecretsGenerator < Base
def add_secrets_key_file
unless File.exist?("config/secrets.yml.key") || File.exist?("config/secrets.yml.enc")
key = Rails::Secrets.generate_key
say "Adding config/secrets.yml.key to store the 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 any encrypted secrets."
say ""
create_file "config/secrets.yml.key", key
say ""
end
end
def ignore_key_file
if File.exist?(".gitignore")
unless File.read(".gitignore").include?(key_ignore)
say "Ignoring config/secrets.yml.key so it won't end up in Git history:"
say ""
append_to_file ".gitignore", key_ignore
say ""
end
else
say "IMPORTANT: Don't commit config/secrets.yml.key. Add this to your ignore file:"
say key_ignore, :on_green
say ""
end
end
def add_encrypted_secrets_file
unless File.exist?("config/secrets.yml.enc")
say "Adding config/secrets.yml.enc to store secrets that needs to be encrypted."
say ""
template "config/secrets.yml.enc" do |prefill|
say ""
say "For now the file contains this but it's been encrypted with the generated key:"
say ""
say prefill, :on_green
say ""
Secrets.encrypt(prefill)
end
say "You can edit encrypted secrets with `bin/rails secrets:edit`."
say "Add this to your config/environments/production.rb:"
say "config.read_encrypted_secrets = true"
end
end
private
def key_ignore
[ "", "# Ignore encrypted secrets key file.", "config/secrets.yml.key", "" ].join("\n")
end
end
end
end

View file

@ -0,0 +1,3 @@
# See `secrets.yml` for tips on generating suitable keys.
# production:
# external_api_key: 1466aac22e6a869134be3d09b9e89232fc2c2289…

View file

@ -0,0 +1,111 @@
require "yaml"
module Rails
# Greatly inspired by Ara T. Howard's magnificent sekrets gem. 😘
class Secrets # :nodoc:
class MissingKeyError < RuntimeError
def initialize
super(<<-end_of_message.squish)
Missing encryption key to decrypt secrets with.
Ask your team for your master key and put it in ENV["RAILS_MASTER_KEY"]
end_of_message
end
end
@read_encrypted_secrets = false
@root = File # Wonky, but ensures `join` uses the current directory.
class << self
attr_writer :root
attr_accessor :read_encrypted_secrets
def parse(paths, env:)
paths.each_with_object(Hash.new) do |path, all_secrets|
require "erb"
secrets = YAML.load(ERB.new(preprocess(path)).result) || {}
all_secrets.merge!(secrets["shared"].deep_symbolize_keys) if secrets["shared"]
all_secrets.merge!(secrets[env].deep_symbolize_keys) if secrets[env]
end
end
def generate_key
cipher = new_cipher
SecureRandom.hex(cipher.key_len)[0, cipher.key_len]
end
def key
ENV["RAILS_MASTER_KEY"] || read_key_file || handle_missing_key
end
def encrypt(text)
cipher(:encrypt, text)
end
def decrypt(data)
cipher(:decrypt, data)
end
def read
decrypt(IO.binread(path))
end
def write(contents)
IO.binwrite("#{path}.tmp", encrypt(contents))
FileUtils.mv("#{path}.tmp", path)
end
def read_for_editing
tmp_path = File.join(Dir.tmpdir, File.basename(path))
IO.binwrite(tmp_path, read)
yield tmp_path
write(IO.binread(tmp_path))
ensure
FileUtils.rm(tmp_path) if File.exist?(tmp_path)
end
private
def handle_missing_key
raise MissingKeyError
end
def read_key_file
if File.exist?(key_path)
IO.binread(key_path).strip
end
end
def key_path
@root.join("config", "secrets.yml.key")
end
def path
@root.join("config", "secrets.yml.enc").to_s
end
def preprocess(path)
if path.end_with?(".enc")
if @read_encrypted_secrets
decrypt(IO.binread(path))
else
""
end
else
IO.read(path)
end
end
def new_cipher
OpenSSL::Cipher.new("aes-256-cbc")
end
def cipher(mode, data)
cipher = new_cipher.public_send(mode)
cipher.key = key
cipher.update(data) << cipher.final
end
end
end
end

View file

@ -335,6 +335,7 @@ class AppGeneratorTest < Rails::Generators::TestCase
end
assert_file "config/environments/production.rb" do |content|
assert_match(/# config\.action_mailer\.raise_delivery_errors = false/, content)
assert_match(/^ config\.read_encrypted_secrets = true/, content)
end
end

View file

@ -0,0 +1,42 @@
require "generators/generators_test_helper"
require "rails/generators/rails/encrypted_secrets/encrypted_secrets_generator"
class EncryptedSecretsGeneratorTest < Rails::Generators::TestCase
include GeneratorsTestHelper
def setup
super
cd destination_root
end
def test_generates_key_file_and_encrypted_secrets_file
run_generator
assert_file "config/secrets.yml.key", /[\w\d]+/
assert File.exist?("config/secrets.yml.enc")
assert_no_match(/production:\n# external_api_key: [\w\d]+/, IO.binread("config/secrets.yml.enc"))
assert_match(/production:\n# external_api_key: [\w\d]+/, Rails::Secrets.read)
end
def test_appends_to_gitignore
FileUtils.touch(".gitignore")
run_generator
assert_file ".gitignore", /config\/secrets.yml.key/, /(?!config\/secrets.yml.enc)/
end
def test_warns_when_ignore_is_missing
assert_match(/Add this to your ignore file/i, run_generator)
end
def test_doesnt_generate_a_new_key_file_if_already_opted_in_to_encrypted_secrets
FileUtils.mkdir("config")
File.open("config/secrets.yml.enc", "w") { |f| f.puts "already secrety" }
run_generator
assert_no_file "config/secrets.yml.key"
end
end

View file

@ -22,6 +22,7 @@ require "active_support/core_ext/object/blank"
require "active_support/testing/isolation"
require "active_support/core_ext/kernel/reporting"
require "tmpdir"
require "rails/secrets"
module TestHelpers
module Paths

View file

@ -0,0 +1,108 @@
require "abstract_unit"
require "isolation/abstract_unit"
require "rails/generators"
require "rails/generators/rails/encrypted_secrets/encrypted_secrets_generator"
require "rails/secrets"
class Rails::SecretsTest < ActiveSupport::TestCase
include ActiveSupport::Testing::Isolation
def setup
build_app
@old_read_encrypted_secrets, Rails::Secrets.read_encrypted_secrets =
Rails::Secrets.read_encrypted_secrets, true
end
def teardown
Rails::Secrets.read_encrypted_secrets = @old_read_encrypted_secrets
teardown_app
end
test "setting read to false skips parsing" do
Rails::Secrets.read_encrypted_secrets = false
Dir.chdir(app_path) do
assert_equal Hash.new, Rails::Secrets.parse(%w( config/secrets.yml.enc ), env: "production")
end
end
test "raises when reading secrets without a key" do
run_secrets_generator do
FileUtils.rm("config/secrets.yml.key")
assert_raises Rails::Secrets::MissingKeyError do
Rails::Secrets.key
end
end
end
test "reading with ENV variable" do
run_secrets_generator do
begin
old_key = ENV["RAILS_MASTER_KEY"]
ENV["RAILS_MASTER_KEY"] = IO.binread("config/secrets.yml.key").strip
FileUtils.rm("config/secrets.yml.key")
assert_match "production:\n# external_api_key", Rails::Secrets.read
ensure
ENV["RAILS_MASTER_KEY"] = old_key
end
end
end
test "reading from key file" do
run_secrets_generator do
File.binwrite("config/secrets.yml.key", "How do I know you feel it?")
assert_equal "How do I know you feel it?", Rails::Secrets.key
end
end
test "editing" do
run_secrets_generator do
decrypted_path = nil
Rails::Secrets.read_for_editing do |tmp_path|
decrypted_path = tmp_path
assert_match(/production:\n# external_api_key/, File.read(tmp_path))
File.write(tmp_path, "Empty streets, empty nights. The Downtown Lights.")
end
assert_not File.exist?(decrypted_path)
assert_equal "Empty streets, empty nights. The Downtown Lights.", Rails::Secrets.read
end
end
test "merging secrets with encrypted precedence" do
run_secrets_generator do
File.write("config/secrets.yml", <<-end_of_secrets)
test:
yeah_yeah: lets-go-walking-down-this-empty-street
end_of_secrets
Rails::Secrets.write(<<-end_of_secrets)
test:
yeah_yeah: lets-walk-in-the-cool-evening-light
end_of_secrets
Rails.application.config.root = app_path
Rails.application.instance_variable_set(:@secrets, nil) # Dance around caching 💃🕺
assert_equal "lets-walk-in-the-cool-evening-light", Rails.application.secrets.yeah_yeah
end
end
private
def run_secrets_generator
Dir.chdir(app_path) do
capture(:stdout) do
Rails::Generators::EncryptedSecretsGenerator.start
end
yield
end
end
end