diff --git a/CHANGELOG.md b/CHANGELOG.md index 405c57d..76f8363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- Internal: Encryptor can now use other ciphers than the default + + ## [0.3.3] - 2020-07-25 diff --git a/README.md b/README.md index d88e021..7f5a0ff 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,8 @@ There are a few ways to use the library, depending on how advanced your use case The easiest way to get started is to use the CLI. ```shell -diffcrypt decrypt -k $(cat test/fixtures/master.key) test/fixtures/example.yml.enc -diffcrypt encrypt -k $(cat test/fixtures/master.key) test/fixtures/example.yml +diffcrypt decrypt -k $(cat test/fixtures/aes-128-gcm.key) test/fixtures/example.yml.enc +diffcrypt encrypt -k $(cat test/fixtures/aes-128-gcm.key) test/fixtures/example.yml ``` @@ -69,7 +69,7 @@ the built in encrypter. All existing `rails credentials:edit` also work with thi require 'diffcrypt/rails/encrypted_configuration' module Rails class Application - def encrypted(path, key_path: 'config/master.key', env_key: 'RAILS_MASTER_KEY') + def encrypted(path, key_path: 'config/aes-128-gcm.key', env_key: 'RAILS_MASTER_KEY') Diffcrypt::Rails::EncryptedConfiguration.new( config_path: Rails.root.join(path), key_path: Rails.root.join(key_path), @@ -83,6 +83,17 @@ end +## Converting between ciphers + +Sometimes you may want to rotate the cipher used on a file. You cab do this rogramtically using the ruby code above, or you can also chain the CLI commands like so: + +```shell +diffcrypt decrypt -k $(cat test/fixtures/aes-128-gcm.key) test/fixtures/example.yml.enc > test/fixtures/example.128.yml \ +&& diffcrypt encrypt --cipher aes-256-gcm -k $(cat test/fixtures/aes-256-gcm.key) test/fixtures/example.128.yml > test/fixtures/example.256.yml.enc && rm test/fixtures/example.128.yml +``` + + + ## Development After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. diff --git a/diffcrypt.gemspec b/diffcrypt.gemspec index c19d5b2..31a2bb1 100644 --- a/diffcrypt.gemspec +++ b/diffcrypt.gemspec @@ -22,7 +22,7 @@ Gem::Specification.new do |spec| # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. - spec.files = Dir.chdir(File.expand_path(__dir__)) do + spec.files = Dir.chdir(::File.expand_path(__dir__)) do `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } end spec.bindir = 'bin' diff --git a/lib/diffcrypt/cli.rb b/lib/diffcrypt/cli.rb index a7ef412..930057f 100644 --- a/lib/diffcrypt/cli.rb +++ b/lib/diffcrypt/cli.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative './encryptor' +require_relative './file' require_relative './version' module Diffcrypt @@ -8,21 +9,22 @@ class CLI < Thor desc 'decrypt ', 'Decrypt a file' method_option :key, aliases: %i[k], required: true def decrypt(path) - ensure_file_exists(path) - contents = File.read(path) - puts encryptor.decrypt(contents) + file = File.new(path) + ensure_file_exists(file) + say file.decrypt(key) end desc 'encrypt ', 'Encrypt a file' method_option :key, aliases: %i[k], required: true + method_option :cipher, default: Encryptor::DEFAULT_CIPHER def encrypt(path) - ensure_file_exists(path) - contents = File.read(path) - puts encryptor.encrypt(contents) + file = File.new(path) + ensure_file_exists(file) + say file.encrypt(key, cipher: options[:cipher]) end desc 'generate-key', 'Generate a 32 bit key' - method_option :cipher, default: Encryptor::CIPHER + method_option :cipher, default: Encryptor::DEFAULT_CIPHER def generate_key say Encryptor.generate_key(options[:cipher]) end @@ -41,8 +43,9 @@ def encryptor @encryptor ||= Encryptor.new(key) end - def ensure_file_exists(path) - abort('[ERROR] File does not exist') unless File.exist?(path) + # @param [Diffcrypt::File] path + def ensure_file_exists(file) + abort('[ERROR] File does not exist') unless file.exists? end def self.exit_on_failure? diff --git a/lib/diffcrypt/encryptor.rb b/lib/diffcrypt/encryptor.rb index c689635..1f394e5 100644 --- a/lib/diffcrypt/encryptor.rb +++ b/lib/diffcrypt/encryptor.rb @@ -12,15 +12,16 @@ module Diffcrypt class Encryptor - CIPHER = 'aes-128-gcm' + DEFAULT_CIPHER = 'aes-128-gcm' - def self.generate_key(cipher = CIPHER) + def self.generate_key(cipher = DEFAULT_CIPHER) SecureRandom.hex(ActiveSupport::MessageEncryptor.key_len(cipher)) end - def initialize(key) + def initialize(key, cipher: DEFAULT_CIPHER) @key = key - @encryptor ||= ActiveSupport::MessageEncryptor.new([key].pack('H*'), cipher: CIPHER) + @cipher = cipher + @encryptor ||= ActiveSupport::MessageEncryptor.new([key].pack('H*'), cipher: cipher) end # @param [String] contents The raw YAML string to be encrypted @@ -50,7 +51,7 @@ def encrypt(contents, original_encrypted_contents = nil) data = encrypt_data contents, original_encrypted_contents YAML.dump( 'client' => "diffcrypt-#{Diffcrypt::VERSION}", - 'cipher' => CIPHER, + 'cipher' => @cipher, 'data' => data, ) end diff --git a/lib/diffcrypt/file.rb b/lib/diffcrypt/file.rb new file mode 100644 index 0000000..9ad6cd6 --- /dev/null +++ b/lib/diffcrypt/file.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative './encryptor' + +module Diffcrypt + class File + attr_reader :file + + def initialize(path) + @path = ::File.absolute_path path + end + + def encrypted? + to_yaml['cipher'] + end + + def cipher + to_yaml['cipher'] || Encryptor::DEFAULT_CIPHER + end + + # @return [Boolean] + def exists? + ::File.exist?(@path) + end + + # @return [String] Raw contents of the file + def read + @read ||= ::File.read(@path) + end + + def encrypt(key, cipher: DEFAULT_CIPHER) + return read if encrypted? + + Encryptor.new(key, cipher: cipher).encrypt(read) + end + + def decrypt(key) + return read unless encrypted? + + Encryptor.new(key).decrypt(read) + end + + def to_yaml + @to_yaml ||= YAML.safe_load(read) + end + end +end diff --git a/lib/diffcrypt/rails/encrypted_configuration.rb b/lib/diffcrypt/rails/encrypted_configuration.rb index 05725ac..fe7dba6 100644 --- a/lib/diffcrypt/rails/encrypted_configuration.rb +++ b/lib/diffcrypt/rails/encrypted_configuration.rb @@ -21,13 +21,18 @@ class EncryptedConfiguration delegate_missing_to :options def initialize(config_path:, key_path:, env_key:, raise_if_missing_key:) - @content_path = Pathname.new(File.absolute_path(config_path)).yield_self do |path| + @content_path = Pathname.new(::File.absolute_path(config_path)).yield_self do |path| path.symlink? ? path.realpath : path end @key_path = Pathname.new(key_path) @env_key = env_key @raise_if_missing_key = raise_if_missing_key - @active_support_encryptor = ActiveSupport::MessageEncryptor.new([key].pack('H*'), cipher: Encryptor::CIPHER) + + # TODO: Use Diffcrypt::File to ensure correct cipher is used + @active_support_encryptor = ActiveSupport::MessageEncryptor.new( + [key].pack('H*'), + cipher: Encryptor::DEFAULT_CIPHER, + ) end # Determines if file is using the diffable format, or still @@ -73,7 +78,7 @@ def change(&block) # rubocop:disable Metrics/AbcSize def writing(contents) tmp_file = "#{Process.pid}.#{content_path.basename.to_s.chomp('.enc')}" - tmp_path = Pathname.new File.join(Dir.tmpdir, tmp_file) + tmp_path = Pathname.new ::File.join(Dir.tmpdir, tmp_file) tmp_path.binwrite contents yield tmp_path diff --git a/test/diffcrypt/encryptor_test.rb b/test/diffcrypt/encryptor_test.rb index f644eda..80fbbbd 100644 --- a/test/diffcrypt/encryptor_test.rb +++ b/test/diffcrypt/encryptor_test.rb @@ -10,7 +10,7 @@ class Diffcrypt::EncryptorTest < Minitest::Test def test_it_includes_client_info_at_root content = "---\nkey: value" - expected_pattern = /---\nclient: diffcrypt-#{Diffcrypt::VERSION}\ncipher: #{Diffcrypt::Encryptor::CIPHER}\ndata:\n key: #{ENCRYPTED_VALUE_PATTERN}\n/ + expected_pattern = /---\nclient: diffcrypt-#{Diffcrypt::VERSION}\ncipher: #{Diffcrypt::Encryptor::DEFAULT_CIPHER}\ndata:\n key: #{ENCRYPTED_VALUE_PATTERN}\n/ assert_match expected_pattern, Diffcrypt::Encryptor.new(TEST_KEY).encrypt(content) end diff --git a/test/diffcrypt/file_test.rb b/test/diffcrypt/file_test.rb new file mode 100644 index 0000000..47f394a --- /dev/null +++ b/test/diffcrypt/file_test.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'thor' + +require_relative '../../lib/diffcrypt/file' + +class Diffcrypt::FileTest < Minitest::Test + def setup + @path = "#{__dir__}/../../tmp/test-content-plain.yml" + @content = 'key: value' + ::File.write(@path, @content) + + @encrypted_path = "#{__dir__}/../../tmp/test-content-encrypted.yml" + @encrypted_content = "client: diffcrypt-test\ncipher: #{Diffcrypt::Encryptor::DEFAULT_CIPHER}\ndata:\n key: value" + ::File.write(@encrypted_path, @encrypted_content) + end + + def test_it_reads_content + file = Diffcrypt::File.new(@path) + assert_equal file.read, @content + end + + def test_it_idntifies_as_unencrypted + file = Diffcrypt::File.new(@path) + refute file.encrypted? + end + + def test_it_idntifies_as_encrypted + file = Diffcrypt::File.new(@encrypted_path) + assert file.encrypted? + end +end diff --git a/test/diffcrypt/rails/encrypted_configuration_test.rb b/test/diffcrypt/rails/encrypted_configuration_test.rb index 8e4799f..4dd5cf3 100644 --- a/test/diffcrypt/rails/encrypted_configuration_test.rb +++ b/test/diffcrypt/rails/encrypted_configuration_test.rb @@ -8,7 +8,7 @@ class Diffcrypt::Rails::EncryptedConfigurationTest < Minitest::Test def configuration Diffcrypt::Rails::EncryptedConfiguration.new( config_path: "#{__dir__}/../../fixtures/example.yml.enc", - key_path: "#{__dir__}/../../fixtures/master.key", + key_path: "#{__dir__}/../../fixtures/aes-128-gcm.key", env_key: 'RAILS_MASTER_KEY', raise_if_missing_key: false, ) @@ -17,10 +17,10 @@ def configuration # This verifies that encrypted and unecrypted data can't be accidently the # same, which would create false positive tests and a major security issue def test_that_fixtures_are_different - refute_equal File.read("#{__dir__}/../../fixtures/example.yml.enc"), File.read("#{__dir__}/../../fixtures/example.yml") + refute_equal ::File.read("#{__dir__}/../../fixtures/example.yml.enc"), ::File.read("#{__dir__}/../../fixtures/example.yml") end def test_that_fixture_can_be_decrypted - assert_equal configuration.read, File.read("#{__dir__}/../../fixtures/example.yml") + assert_equal configuration.read, ::File.read("#{__dir__}/../../fixtures/example.yml") end end diff --git a/test/fixtures/master.key b/test/fixtures/aes-128-gcm.key similarity index 100% rename from test/fixtures/master.key rename to test/fixtures/aes-128-gcm.key diff --git a/test/fixtures/aes-256-gcm.key b/test/fixtures/aes-256-gcm.key new file mode 100644 index 0000000..fb295a8 --- /dev/null +++ b/test/fixtures/aes-256-gcm.key @@ -0,0 +1 @@ +35bc348e9cbca231e05279ba668658333b45754e2b423c85fac831317d6f7bba diff --git a/test/test_helper.rb b/test/test_helper.rb index 03b3223..fa8fd7d 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -21,6 +21,6 @@ # # @example Generate an expected value for tests # Diffcrypt::Encryptor.new('99e1f86b9e61f24c56ff4108dd415091').encrypt_string('some value here') -TEST_KEY = File.read("#{__dir__}/fixtures/master.key").strip +TEST_KEY = ::File.read("#{__dir__}/fixtures/aes-128-gcm.key").strip require 'minitest/autorun' diff --git a/tmp/.keep b/tmp/.keep new file mode 100644 index 0000000..e69de29