Skip to content

Commit bb69f81

Browse files
committed
Add rails support via monkey patch
1 parent 20a1962 commit bb69f81

File tree

9 files changed

+200
-8
lines changed

9 files changed

+200
-8
lines changed

README.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Or install it yourself as:
2929

3030
```ruby
3131
encryptor = Diffcrypt::Encryptor.new('99e1f86b9e61f24c56ff4108dd415091')
32-
yaml = File.read('tmp/example.yml')
32+
yaml = File.read('test/fixtures/example.yml')
3333
encrypted = encryptor.encrypt(yaml)
3434
File.write('tmp/example.yml.enc', encrypted)
3535
```
@@ -38,10 +38,31 @@ File.write('tmp/example.yml.enc', encrypted)
3838

3939
```ruby
4040
encryptor = Diffcrypt::Encryptor.new('99e1f86b9e61f24c56ff4108dd415091')
41-
yaml = File.read('tmp/example.yml.enc')
41+
yaml = File.read('test/fixtures/example.yml.enc')
4242
config = YAML.safe_load(encryptor.decrypt(yaml))
4343
```
4444

45+
### Rails
46+
47+
Currently there is not native support for rails, but ActiveSupport can be monkeypatched to override
48+
the build in encrypter.
49+
50+
```ruby
51+
require 'diffcrypt/rails/encrypted_configuration'
52+
module Rails
53+
class Application
54+
def encrypted(path, key_path: 'config/master.key', env_key: 'RAILS_MASTER_KEY')
55+
Diffcrypt::Rails::EncryptedConfiguration.new(
56+
config_path: Rails.root.join(path),
57+
key_path: Rails.root.join(key_path),
58+
env_key: env_key,
59+
raise_if_missing_key: config.require_master_key,
60+
)
61+
end
62+
end
63+
end
64+
```
65+
4566

4667

4768
## Development

lib/diffcrypt.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,18 @@
55

66
module Diffcrypt
77
class Error < StandardError; end
8+
9+
class MissingContentError < RuntimeError
10+
def initialize(content_path)
11+
super "Missing encrypted content file in #{content_path}."
12+
end
13+
end
14+
15+
class MissingKeyError < RuntimeError
16+
def initialize(key_path:, env_key:)
17+
super \
18+
'Missing encryption key to decrypt file with. ' \
19+
"Ask your team for your master key and write it to #{key_path} or put it in the ENV['#{env_key}']."
20+
end
21+
end
822
end
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# frozen_string_literal: true
2+
3+
require 'pathname'
4+
require 'tmpdir'
5+
6+
require 'active_support/ordered_options'
7+
require 'active_support/core_ext/hash'
8+
require 'active_support/core_ext/module/delegation'
9+
require 'active_support/core_ext/object/inclusion'
10+
11+
module Diffcrypt
12+
module Rails
13+
class EncryptedConfiguration
14+
attr_reader :content_path
15+
attr_reader :key_path
16+
attr_reader :env_key
17+
attr_reader :raise_if_missing_key
18+
19+
delegate :[], :fetch, to: :config
20+
delegate_missing_to :options
21+
22+
def initialize(config_path:, key_path:, env_key:, raise_if_missing_key:)
23+
@content_path = Pathname.new(File.absolute_path(config_path)).yield_self do |path|
24+
path.symlink? ? path.realpath : path
25+
end
26+
@key_path = Pathname.new(key_path)
27+
@env_key = env_key
28+
@raise_if_missing_key = raise_if_missing_key
29+
@active_support_encryptor = ActiveSupport::MessageEncryptor.new([key].pack('H*'), cipher: Encryptor::CIPHER)
30+
end
31+
32+
# Allow a config to be started without a file present
33+
# @return [String] Returns decryped content or a blank string
34+
def read
35+
raise MissingContentError, content_path unless !key.nil? && content_path.exist?
36+
37+
decrypt content_path.binread
38+
rescue MissingContentError
39+
''
40+
end
41+
42+
def write(contents)
43+
deserialize(contents)
44+
45+
IO.binwrite "#{content_path}.tmp", encrypt(contents)
46+
FileUtils.mv "#{content_path}.tmp", content_path
47+
end
48+
49+
def config
50+
@config ||= deserialize(read).deep_symbolize_keys
51+
end
52+
53+
# @raise [MissingKeyError] Will raise if key is not set
54+
# @return [String]
55+
def key
56+
read_env_key || read_key_file || handle_missing_key
57+
end
58+
59+
def change(&block)
60+
writing read, &block
61+
end
62+
63+
protected
64+
65+
def writing(contents)
66+
tmp_file = "#{Process.pid}.#{content_path.basename.to_s.chomp('.enc')}"
67+
tmp_path = Pathname.new File.join(Dir.tmpdir, tmp_file)
68+
tmp_path.binwrite contents
69+
70+
yield tmp_path
71+
72+
updated_contents = tmp_path.binread
73+
74+
write(updated_contents) if updated_contents != contents
75+
ensure
76+
FileUtils.rm(tmp_path) if tmp_path&.exist?
77+
end
78+
79+
# @param [String] contents
80+
# @return [String]
81+
def encrypt(contents)
82+
encryptor.encrypt contents
83+
end
84+
85+
# @param [String] contents
86+
# @return [String]
87+
def decrypt(contents)
88+
if contents.index('---').nil?
89+
@active_support_encryptor.decrypt_and_verify contents
90+
else
91+
encryptor.decrypt contents
92+
end
93+
end
94+
95+
# @return [Encryptor]
96+
def encryptor
97+
@encryptor ||= Encryptor.new key
98+
end
99+
100+
def read_env_key
101+
ENV[env_key]
102+
end
103+
104+
def read_key_file
105+
key_path.binread.strip if key_path.exist?
106+
end
107+
108+
# @raise [MissingKeyError]
109+
# @return [void]
110+
def handle_missing_key
111+
raise MissingKeyError.new(key_path: key_path, env_key: env_key) if raise_if_missing_key
112+
end
113+
114+
def options
115+
@options ||= ActiveSupport::InheritableOptions.new(config)
116+
end
117+
118+
def deserialize(config)
119+
YAML.safe_load(config).presence || {}
120+
end
121+
end
122+
end
123+
end

test/diffcrypt/encryptor_test.rb

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@
22

33
require 'test_helper'
44

5-
ENCRYPTED_VALUE_PATTERN = '([a-z0-9A-Z=/+]+)\-\-([a-z0-9A-Z=/+]+)\-\-([a-z0-9A-Z=/+]+)'
5+
# Since the encrypted values use openssl and are non-deterministic, we can never know the
6+
# actual value to test against. All we can do is ensure the value is in the correct format
7+
# for the encrypted content, which verifies it's not in the original state
8+
ENCRYPTED_VALUE_PATTERN = %('?([a-z0-9A-Z=/+]+)\-\-([a-z0-9A-Z=/+]+)\-\-([a-z0-9A-Z=/+]+)'?)
69

710
class Diffcrypt::EncryptorTest < Minitest::Test
8-
def test_that_it_has_a_version_number
9-
refute_nil ::Diffcrypt::VERSION
10-
end
11-
1211
def test_it_decrypts_root_values
1312
encrypted_content = <<~CONTENT
1413
secret_key_base: 88Ry6HESUoXBr6QUFXmni9zzfCIYt9qGNFvIWFcN--4xoecI5mqbNRBibI--62qPJbkzzh5h8lhFEFOSaQ==
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
require 'test_helper'
4+
5+
require 'diffcrypt/rails/encrypted_configuration'
6+
7+
class Diffcrypt::Rails::EncryptedConfigurationTest < Minitest::Test
8+
def configuration
9+
Diffcrypt::Rails::EncryptedConfiguration.new(
10+
config_path: "#{__dir__}/../../fixtures/example.yml.enc",
11+
key_path: "#{__dir__}/../../fixtures/master.key",
12+
env_key: 'RAILS_MASTER_KEY',
13+
raise_if_missing_key: false
14+
)
15+
end
16+
17+
# This verifies that encrypted and unecrypted data can't be accidently the
18+
# same, which would create false positive tests and a major security issue
19+
def test_that_fixtures_are_different
20+
refute_equal File.read("#{__dir__}/../../fixtures/example.yml.enc"), File.read("#{__dir__}/../../fixtures/example.yml")
21+
end
22+
23+
def test_that_fixture_can_be_decrypted
24+
assert_equal configuration.read, File.read("#{__dir__}/../../fixtures/example.yml")
25+
end
26+
end

test/fixtures/example.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
secret_key_base: secret_key_base_test
3+
aws:
4+
access_key_id: AKIXXX

test/fixtures/example.yml.enc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
secret_key_base: q/nKM+MSI0IAUUGTxn336uQDgDXbJxDu6GNI3vlL--I6DThmOJBJYK6SDZ--8wmyBE0O3tGtiZK8gw69Bg==
3+
aws:
4+
access_key_id: HEnbrD9fiNclsf2hFNAyPw==--hbJkPko9OV1QXxAq--zEkhplu33aZ9a5YCPs6mlg==

test/fixtures/master.key

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
99e1f86b9e61f24c56ff4108dd415091

test/test_helper.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77
#
88
# @example Generate an expected value for tests
99
# Diffcrypt::Encryptor.new('99e1f86b9e61f24c56ff4108dd415091').encrypt_string('some value here')
10-
TEST_KEY = '99e1f86b9e61f24c56ff4108dd415091'
10+
TEST_KEY = File.read("#{__dir__}/fixtures/master.key").strip
1111

1212
require 'minitest/autorun'

0 commit comments

Comments
 (0)