提交 fbee4e3c 编写于 作者: K Kasper Timm Hansen 提交者: GitHub

Revert "Revert "Add encrypted secrets""

上级 4734d23c
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
require "active_support/key_generator" require "active_support/key_generator"
require "active_support/message_verifier" require "active_support/message_verifier"
require "rails/engine" require "rails/engine"
require "rails/secrets"
module Rails module Rails
# An Engine with the responsibility of coordinating the whole boot process. # An Engine with the responsibility of coordinating the whole boot process.
...@@ -385,18 +386,7 @@ def config=(configuration) #:nodoc: ...@@ -385,18 +386,7 @@ def config=(configuration) #:nodoc:
def secrets def secrets
@secrets ||= begin @secrets ||= begin
secrets = ActiveSupport::OrderedOptions.new secrets = ActiveSupport::OrderedOptions.new
yaml = config.paths["config/secrets"].first secrets.merge! Rails::Secrets.parse(config.paths["config/secrets"].existent, env: Rails.env)
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
# Fallback to config.secret_key_base if secrets.secret_key_base isn't set # Fallback to config.secret_key_base if secrets.secret_key_base isn't set
secrets.secret_key_base ||= config.secret_key_base secrets.secret_key_base ||= config.secret_key_base
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
require "active_support/notifications" require "active_support/notifications"
require "active_support/dependencies" require "active_support/dependencies"
require "active_support/descendants_tracker" require "active_support/descendants_tracker"
require "rails/secrets"
module Rails module Rails
class Application class Application
...@@ -77,6 +78,11 @@ module Bootstrap ...@@ -77,6 +78,11 @@ module Bootstrap
initializer :bootstrap_hook, group: :all do |app| initializer :bootstrap_hook, group: :all do |app|
ActiveSupport.run_load_hooks(:before_initialize, app) ActiveSupport.run_load_hooks(:before_initialize, app)
end 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 end
end end
...@@ -13,7 +13,8 @@ class Configuration < ::Rails::Engine::Configuration ...@@ -13,7 +13,8 @@ class Configuration < ::Rails::Engine::Configuration
:railties_order, :relative_url_root, :secret_key_base, :secret_token, :railties_order, :relative_url_root, :secret_key_base, :secret_token,
:ssl_options, :public_file_server, :ssl_options, :public_file_server,
:session_options, :time_zone, :reload_classes_only_on_change, :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_writer :log_level
attr_reader :encoding, :api_only attr_reader :encoding, :api_only
...@@ -51,6 +52,7 @@ def initialize(*) ...@@ -51,6 +52,7 @@ def initialize(*)
@debug_exception_response_format = nil @debug_exception_response_format = nil
@x = Custom.new @x = Custom.new
@enable_dependency_loading = false @enable_dependency_loading = false
@read_encrypted_secrets = false
end end
def encoding=(value) def encoding=(value)
...@@ -80,7 +82,7 @@ def paths ...@@ -80,7 +82,7 @@ def paths
@paths ||= begin @paths ||= begin
paths = super paths = super
paths.add "config/database", with: "config/database.yml" 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 "config/environment", with: "config/environment.rb"
paths.add "lib/templates" paths.add "lib/templates"
paths.add "log", with: "log/#{Rails.env}.log" paths.add "log", with: "log/#{Rails.env}.log"
......
...@@ -27,15 +27,22 @@ def environment # :nodoc: ...@@ -27,15 +27,22 @@ def environment # :nodoc:
end end
# Receives a namespace, arguments and the behavior to invoke the command. # Receives a namespace, arguments and the behavior to invoke the command.
def invoke(namespace, args = [], **config) def invoke(full_namespace, args = [], **config)
namespace = namespace.to_s namespace = full_namespace = full_namespace.to_s
namespace = "help" if namespace.blank? || HELP_MAPPINGS.include?(namespace)
namespace = "version" if %w( -v --version ).include? namespace
if command = find_by_namespace(namespace) if char = namespace =~ /:(\w+)$/
command.perform(namespace, args, config) command_name, namespace = $1, namespace.slice(0, char)
else 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
end end
...@@ -52,8 +59,10 @@ def invoke(namespace, args = [], **config) ...@@ -52,8 +59,10 @@ def invoke(namespace, args = [], **config)
# #
# Notice that "rails:commands:webrat" could be loaded as well, what # Notice that "rails:commands:webrat" could be loaded as well, what
# Rails looks for is the first and last parts of the namespace. # Rails looks for is the first and last parts of the namespace.
def find_by_namespace(name) # :nodoc: def find_by_namespace(namespace, command_name = nil) # :nodoc:
lookups = [ name, "rails:#{name}" ] lookups = [ namespace ]
lookups << "#{namespace}:#{command_name}" if command_name
lookups.concat lookups.map { |lookup| "rails:#{lookup}" }
lookup(lookups) lookup(lookups)
......
...@@ -56,7 +56,9 @@ def inherited(base) #:nodoc: ...@@ -56,7 +56,9 @@ def inherited(base) #:nodoc:
end end
def perform(command, args, config) # :nodoc: 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) dispatch(command, args.dup, nil, config)
end end
...@@ -111,7 +113,7 @@ def usage_path ...@@ -111,7 +113,7 @@ def usage_path
# For a `Rails::Command::TestCommand` placed in `rails/command/test_command.rb` # For a `Rails::Command::TestCommand` placed in `rails/command/test_command.rb`
# would return `rails/test`. # would return `rails/test`.
def default_command_root 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) path if File.exist?(path)
end end
...@@ -129,6 +131,10 @@ def create_command(meth) ...@@ -129,6 +131,10 @@ def create_command(meth)
super super
end end
end end
def command_root_namespace
(namespace.split(":") - %w( rails )).first
end
end end
def help def help
......
=== 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.
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
...@@ -214,6 +214,7 @@ def sorted_groups ...@@ -214,6 +214,7 @@ def sorted_groups
rails.map! { |n| n.sub(/^rails:/, "") } rails.map! { |n| n.sub(/^rails:/, "") }
rails.delete("app") rails.delete("app")
rails.delete("plugin") rails.delete("plugin")
rails.delete("encrypted_secrets")
hidden_namespaces.each { |n| groups.delete(n.to_s) } hidden_namespaces.each { |n| groups.delete(n.to_s) }
......
...@@ -14,6 +14,11 @@ Rails.application.configure do ...@@ -14,6 +14,11 @@ Rails.application.configure do
config.consider_all_requests_local = false config.consider_all_requests_local = false
config.action_controller.perform_caching = true 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 # Disable serving static files from the `/public` folder by default since
# Apache or NGINX already handles this. # Apache or NGINX already handles this.
config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
......
...@@ -23,8 +23,10 @@ development: ...@@ -23,8 +23,10 @@ development:
test: test:
secret_key_base: <%= app_secret %> secret_key_base: <%= app_secret %>
# Do not keep production secrets in the repository, # Do not keep production secrets in the unencrypted secrets file.
# instead read values from the environment. # 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: production:
secret_key_base: <%%= ENV["SECRET_KEY_BASE"] %> secret_key_base: <%%= ENV["SECRET_KEY_BASE"] %>
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
# See `secrets.yml` for tips on generating suitable keys.
# production:
# external_api_key: 1466aac22e6a869134be3d09b9e89232fc2c2289…
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
...@@ -335,6 +335,7 @@ def test_generator_without_skips ...@@ -335,6 +335,7 @@ def test_generator_without_skips
end end
assert_file "config/environments/production.rb" do |content| assert_file "config/environments/production.rb" do |content|
assert_match(/# config\.action_mailer\.raise_delivery_errors = false/, content) assert_match(/# config\.action_mailer\.raise_delivery_errors = false/, content)
assert_match(/^ config\.read_encrypted_secrets = true/, content)
end end
end end
......
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
...@@ -22,6 +22,7 @@ ...@@ -22,6 +22,7 @@
require "active_support/testing/isolation" require "active_support/testing/isolation"
require "active_support/core_ext/kernel/reporting" require "active_support/core_ext/kernel/reporting"
require "tmpdir" require "tmpdir"
require "rails/secrets"
module TestHelpers module TestHelpers
module Paths module Paths
......
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
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册