secret_keys 1.0.0.pre

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: db4d05a36d5a69f053949e6be11cdedde1203d87d57e41b4609ffbea7118e3b6
4
+ data.tar.gz: f753189aaed3cae64ef13029a47ee68616a7ca95d309f2607c5427450f7ba63c
5
+ SHA512:
6
+ metadata.gz: 73b58cba572869c1e9a399620ee56b6acdc5b288a1f95337e611bd84ae7231417bbeeaaf3217bc28daec006cec304c79ed62ad6c86ee5acd25513689bfcd8f22
7
+ data.tar.gz: 9f0c00479cbbfad0d14695f6b446c7427e74865a47f9f05d26e16ad45179405bf82452fc54b283ff73433d93f268998b148eac050fb0583ea0ab58c98f2cf26e
@@ -0,0 +1,8 @@
1
+ root = true
2
+
3
+ [*]
4
+ indent_style = space
5
+ indent_size = 2
6
+ charset = utf-8
7
+ trim_trailing_whitespace = true
8
+ insert_final_newline = true
@@ -0,0 +1,11 @@
1
+ name: Style checks
2
+ on: [push, pull_request]
3
+ jobs:
4
+ style:
5
+ name: Standard RB style check
6
+ runs-on: ubuntu-latest
7
+ steps:
8
+ - name: standardrb
9
+ env:
10
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
11
+ uses: amoeba/standardrb-action@v3
@@ -0,0 +1,42 @@
1
+ # Test runner copied from R167/backblaze
2
+ name: Run tests
3
+ on: [push, pull_request]
4
+ jobs:
5
+ rspec:
6
+ name: rspec ruby-${{ matrix.ruby }} (${{ matrix.os }})
7
+ runs-on: ${{ matrix.os }}
8
+ strategy:
9
+ fail-fast: false
10
+ matrix:
11
+ os: [ ubuntu-latest ]
12
+ ruby: [ 2.4, 2.5, 2.6, 2.7 ]
13
+ include: # define single matrix case that performs upload
14
+ - os: ubuntu-latest
15
+ ruby: 2.7
16
+ upload: "true"
17
+ steps:
18
+ - name: checkout
19
+ uses: actions/checkout@v2
20
+ - name: set up Ruby
21
+ uses: ruby/setup-ruby@v1
22
+ with:
23
+ ruby-version: ${{ matrix.ruby }}
24
+ # Caching makes life better
25
+ - name: build lockfile
26
+ run: |
27
+ bundle config path vendor/bundle
28
+ bundle lock
29
+ - uses: actions/cache@v1
30
+ with:
31
+ path: vendor/bundle
32
+ key: bundle-use-ruby-${{ matrix.os }}-${{ matrix.ruby }}-${{ hashFiles('**/Gemfile.lock') }}
33
+ restore-keys: |
34
+ bundle-use-ruby-${{ matrix.os }}-${{ matrix.ruby }}-
35
+ bundle-use-ruby-${{ matrix.os }}-
36
+ - name: install dependencies
37
+ run: |
38
+ bundle install --jobs 3 --retry 3
39
+ bundle clean
40
+ # Okay, back to what we really care about...
41
+ - name: spec
42
+ run: bundle exec rake spec
@@ -0,0 +1,7 @@
1
+ # I really just have issues with the automatic "semantic blocks"
2
+
3
+ format: progress
4
+
5
+ ignore:
6
+ - '**/*':
7
+ - Standard/SemanticBlocks
@@ -0,0 +1,3 @@
1
+ --markup markdown
2
+ --protected
3
+ --no-private
@@ -0,0 +1,3 @@
1
+ # 1.0.0
2
+
3
+ Initial release
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2020 Brian Durand
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,126 @@
1
+ # SecretKeys
2
+
3
+ This ruby gem handles encrypting values in a JSON or YAML file. It is yet another solution for storing secrets in a ruby project.
4
+
5
+ The main advantage offered by this gem is that it stores the files in standard JSON format and it can easily store both encrypted and non-encrypted values side-by-side, so easily track all your configurations in one place. After providing your secret key, all values can be easily accessed regardless of whether they were encrypted or plaintext.
6
+
7
+ Encrypted values are stored using aes-256-gcm, and the key is derived from your password secret and salt using PBKDF2. All security primitives are provided by openssl, based on recommendations put forth in the libsodium crypto suite.
8
+
9
+ ## Usage
10
+
11
+ You can load the JSON/YAML from a file
12
+
13
+ ```ruby
14
+ secrets = SecretKeys.new("/path/to/file.json", "mysecretkey")
15
+ ```
16
+
17
+ or a stream
18
+
19
+ ```ruby
20
+ secrets = SecretKeys.new(File.open("/path/to/file.json"), "mysecretkey")
21
+ ```
22
+
23
+ If you don't supply the encryption key in the constructor, by default it will be read from the `SECRET_KEYS_ENCRYPTION_KEY` environment variable. If that value is not present, then it will attempted to be read from the file path in the `SECRET_KEYS_ENCRYPTION_KEY_FILE` environment variable. As a side note, the empty string (`""`) is not considered a valid secret, so encryption **will** fail if ther is no explicitly passed secret and no ENV variable.
24
+
25
+ The `SecretKeys` object delegates to `Hash` and can be treated as a hash for most purposes.
26
+
27
+ ```ruby
28
+ password = secrets["password"]
29
+ ```
30
+
31
+ You can add values to the hash as well and move keys between being encrypted/unencrypted at rest. The values are always stored unencrypted in memory, but you can save them to a JSON file.
32
+
33
+ ```ruby
34
+ # api_key is plaintext by default
35
+ secrets["api_key"] = "1234567890"
36
+
37
+ # mark api_key as a secret to encrypt
38
+ secrets.encrypt!("api_key")
39
+
40
+ # now, when we save, the value for api_key is encrypted
41
+ secrets.save("/path/to/file.json")
42
+ ```
43
+
44
+ Note that since the hash must be serialized to JSON, only JSON compatible keys and values (string, number, boolean, null, array, hash) can be used. The same holds for YAML.
45
+
46
+ Only string values can be encrypted. The encryption is recusive, so all strings in an array or hash in the encrypted keys will be encrypted. See the example below.
47
+
48
+ ```json
49
+ {
50
+ ".encrypted": {
51
+ "enc_key1": {
52
+ "num": 1,
53
+ "rec": ["<encrypted-val>", true],
54
+ "thing": "<encrypted-val>"
55
+ },
56
+ "enc_key2": "<encrypted-val>"
57
+ },
58
+ "unenc_key": "plaintext"
59
+ }
60
+ ```
61
+
62
+ ## Command Line Tool
63
+
64
+ You can use the `secret_keys` command line tool to manage your JSON files.
65
+
66
+ You can initialize a new file with the encrypt command.
67
+
68
+ ```bash
69
+ secret_keys encrypt --key mysecret /path/to/file.json
70
+ ```
71
+
72
+ If you don't specify the `--key` argument, the encryption key will either be read from the STDIN stream or from the `SECRET_KEYS_ENCRYPTION_KEY` environment variable.
73
+
74
+ You can then use your favorite text editor to edit the values in the JSON file. When you are done, you can run the same command again to encrypt the file.
75
+
76
+ You can add or modify keys through the command line as well.
77
+
78
+ ```bash
79
+ # add an encrypted key
80
+ secret_keys encrypt --key mysecret --set password /path/to/file.json
81
+
82
+ # add an encrypted key with a value
83
+ secret_keys encrypt --key mysecret --set password=value /path/to/file.json
84
+
85
+ # encrypt all keys in the file
86
+ secret_keys encrypt --key mysecret --all /path/to/file.json
87
+ ```
88
+
89
+ You can also decrypt or delete keys.
90
+
91
+ ```bash
92
+ secret_keys encrypt --key mysecret --decrypt username --delete password /path/to/file.json
93
+ ```
94
+
95
+ You can change the encryption key used in the file.
96
+
97
+ ```bash
98
+ secret_keys encrypt --key mysecret --new-key newsecret /path/to/file.json
99
+ ```
100
+
101
+ Finally, you can print the unencrypted file to STDOUT.
102
+
103
+ ```bash
104
+ secret_keys decrypt --key mysecret /path/to/file.json
105
+ ```
106
+
107
+ ## File Format
108
+
109
+ The data can be stored in a plain old JSON file. Any unencrypted keys will appear under in the special `".encrypted"` key in the hash. The encryption key itself is also stored in the `".key"` key along with the encrypted values. This is used to confirm that the correct key is being used when decrypting the file.
110
+
111
+ In this example, `key_1` is stored in plain text while `key_2` has been encrypted.
112
+
113
+ ```json
114
+ {
115
+ ".encrypted": {
116
+ ".salt": "aecdfdb296983ec0",
117
+ ".key": "$AES$:LNkaWu/g7gM7zu4qC/4FAGOANOLWcY86uqxQfFiHRVSvXSA23pY",
118
+ "foo": "$AES$:XcbGIW9ABbfcMv79+YK0MC8P7WWtEAfE2Y8S/MMN5Q",
119
+ "array": [
120
+ "$AES$:1WPr25fkbVbQWvTCiEHJOPT50970Z+D8qkYTnTk",
121
+ "$AES$:FgSCK3pG8RBtYFqzO/WmNwus2SABI5zGGmfkPEw"
122
+ ],
123
+ },
124
+ "not_encrypted": "plain text value"
125
+ }
126
+ ```
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 1.0.0.pre
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # TODO this command tool will be rewritten with better, more consistent options.
4
+
5
+ require 'optparse'
6
+ require_relative "../lib/secret_keys"
7
+
8
+ # Load enironment variables from .env file in current working directory if available.
9
+ if File.exist?(File.expand_path(".env"))
10
+ begin
11
+ require 'dotenv/load'
12
+ rescue LoadError
13
+ # Ignore; dotenv gem not available.
14
+ end
15
+ end
16
+
17
+ encryption_key = nil
18
+ new_encryption_key = nil
19
+ add_keys = []
20
+ decrypt_keys = []
21
+ delete_keys = []
22
+ add_all = false
23
+
24
+ options = ARGV.dup
25
+ action = options.shift
26
+
27
+ parser = OptionParser.new do |opts|
28
+ opts.banner = "Usage: secret_keys encrypt|decrypt [options] file_path"
29
+
30
+ opts.on('--key ENCRYPTION_KEY', String, "Encryption key used to encrypt strings in the JSON file. This value can also be passed in the SECRET_KEYS_ENCRYPTION_KEY environment variable or via STDIN.") do |value|
31
+ encryption_key = value
32
+ end
33
+
34
+ opts.on('--set JSON_KEY', String, "Add a key to the encrypted elements. You can specify the value as well by using key=value.") do |value|
35
+ add_keys << value
36
+ end
37
+
38
+ opts.on('--all', "Add all keys to the encrypted elements.") do
39
+ add_all = true
40
+ end
41
+
42
+ opts.on('--decrypt JSON_KEY', String, "Move a key to the decrypted elements. You can specify the value as well by using key=value.") do |value|
43
+ decrypt_keys << value
44
+ end
45
+
46
+ opts.on('--delete JSON_KEY', String, "Remove a key.") do |value|
47
+ delete_keys << value
48
+ end
49
+
50
+ opts.on('--new-key ENCRYPTION_KEY', String, "Set a new encryption key for the file.") do |value|
51
+ new_encryption_key = value
52
+ end
53
+
54
+ opts.on("--help", "Prints this help") do
55
+ puts opts.help
56
+ exit
57
+ end
58
+ end
59
+ parser.parse!(options)
60
+
61
+ unless ["encrypt", "decrypt"].include?(action)
62
+ STDERR.puts parser.banner
63
+ exit 1
64
+ end
65
+
66
+ file_path = options[0]
67
+ unless file_path
68
+ STDERR.puts parser.banner
69
+ exit 1
70
+ end
71
+
72
+ unless encryption_key
73
+ encryption_key = STDIN.read.chomp if STDIN.ready?
74
+ end
75
+
76
+ secrets = nil
77
+ if File.exist?(file_path)
78
+ secrets = SecretKeys.new(file_path, encryption_key)
79
+ else
80
+ secrets = SecretKeys.new(nil, encryption_key)
81
+ end
82
+
83
+ if action == "encrypt"
84
+ secrets.encryption_key = new_encryption_key if new_encryption_key
85
+
86
+ if add_all
87
+ secrets.keys.each do |key|
88
+ secrets.encrypt!(key)
89
+ end
90
+ end
91
+
92
+ add_keys.each do |key|
93
+ if key.include?("=")
94
+ key, value = key.split("=", 2)
95
+ secrets[key] = value
96
+ end
97
+ secrets[key] = nil unless secrets.include?(key)
98
+ secrets.encrypt!(key)
99
+ end
100
+
101
+ decrypt_keys.each do |key|
102
+ if key.include?("=")
103
+ key, value = key.split("=", 2)
104
+ secrets[key] = value
105
+ end
106
+ secrets[key] = nil unless secrets.include?(key)
107
+ secrets.decrypt!(key)
108
+ end
109
+
110
+ delete_keys.each do |key|
111
+ secrets.delete(key)
112
+ end
113
+
114
+ secrets.save(file_path)
115
+ elsif action == "decrypt"
116
+ if File.exist?(file_path)
117
+ if file_path.end_with?(".yaml") || file_path.end_with?(".yml")
118
+ STDOUT.write(YAML.dump(secrets.to_h))
119
+ else
120
+ STDOUT.puts(JSON.pretty_generate(secrets.to_h))
121
+ end
122
+ else
123
+ STDERR.puts("File not found: #{file_path}")
124
+ exit 1
125
+ end
126
+ else
127
+ exit 1
128
+ end
@@ -0,0 +1,427 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "json"
5
+ require "yaml"
6
+ require "securerandom"
7
+ require "delegate"
8
+ require "set"
9
+ require "pathname"
10
+ require "base64"
11
+
12
+ # Load a JSON file with encrypted values. This value can be used as a hash.
13
+ class SecretKeys < DelegateClass(Hash)
14
+ class EncryptionKeyError < ArgumentError; end
15
+
16
+ class << self
17
+ # Encrypt a string with the encryption key. Encrypted values are also salted so
18
+ # calling this function multiple times will result in different values. Only strings
19
+ # can be encrypted. Any other object type will be returned the value passed in.
20
+ #
21
+ # @param [String] str string to encrypt (assumes UTF-8)
22
+ # @param [String] secret_key 32 byte ASCII-8BIT encryption key
23
+ # @return [String] Base64 encoded encrypted string with all aes parameters
24
+ def encrypt(str, secret_key)
25
+ return str unless str.is_a?(String) && secret_key
26
+ return "" if str == ""
27
+
28
+ cipher = OpenSSL::Cipher.new(CIPHER).encrypt
29
+
30
+ # Technically, this is a "bad" way to do things since we could theoretically
31
+ # get a repeat nonce, compromising the algorithm. That said, it should be safe
32
+ # from repeats as long as we don't use this key for more than 2^32 encryptions
33
+ # so... rotate your keys/salt ever 4 billion encryption calls
34
+ nonce = cipher.random_iv
35
+ cipher.key = secret_key
36
+ cipher.auth_data = ""
37
+
38
+ # Make sure the string is encoded as UTF-8. JSON/YAML only support string types
39
+ # anyways, so if you passed in binary data, it was gonna fail anyways. This ensures
40
+ # that we can easily decode the string later. If you have UTF-16 or something, deal with it.
41
+ utf8_str = str.encode(Encoding::UTF_8)
42
+ encrypted_data = cipher.update(utf8_str) + cipher.final
43
+ auth_tag = cipher.auth_tag
44
+
45
+ params = CipherParams.new(nonce, auth_tag, encrypted_data)
46
+
47
+ encode_aes(params).prepend(ENCRYPTED_PREFIX)
48
+ end
49
+
50
+ # Decrypt a string with the encryption key. If the value is not a string or it was
51
+ # not encrypted with the encryption key, the value itself will be returned.
52
+ #
53
+ # @param [String] encrypted_str Base64 encoded encrypted string with aes params (from `.encrypt`)
54
+ # @param [String] secret_key 32 byte ASCII-8BIT encryption key
55
+ # @return [String] decrypted string value
56
+ def decrypt(encrypted_str, secret_key)
57
+ return encrypted_str unless encrypted_str.is_a?(String) && secret_key
58
+ return encrypted_str unless encrypted_str.start_with?(ENCRYPTED_PREFIX)
59
+
60
+ decrypt_str = encrypted_str[ENCRYPTED_PREFIX.length..-1]
61
+ params = decode_aes(decrypt_str)
62
+
63
+ cipher = OpenSSL::Cipher.new(CIPHER).decrypt
64
+
65
+ cipher.key = secret_key
66
+ cipher.iv = params.nonce
67
+ cipher.auth_tag = params.auth_tag
68
+ cipher.auth_data = ""
69
+
70
+ decoded_str = cipher.update(params.data) + cipher.final
71
+
72
+ # force to utf-8 encoding. We already ensured this when we encoded in the first place
73
+ decoded_str.force_encoding(Encoding::UTF_8)
74
+ end
75
+
76
+ private
77
+
78
+ # format: <nonce:12>, <auth_tag:16>, <data:*>
79
+ ENCODING_FORMAT = "a12 a16 a*"
80
+ ENCRYPTED_PREFIX = "$AES$:"
81
+ CIPHER = "aes-256-gcm"
82
+
83
+ # Basic struct to contain nonce, auth_tag, and data for passing around. Thought
84
+ # it was better than just passing an Array with positional params.
85
+ # @private
86
+ CipherParams = Struct.new(:nonce, :auth_tag, :data)
87
+
88
+ # Receive a cipher object (initialized with key) and data
89
+ def encode_aes(params)
90
+ encoded = params.values.pack(ENCODING_FORMAT)
91
+ # encode base64 and get rid of trailing newline and unnecessary =
92
+ Base64.encode64(encoded).chomp.tr("=", "")
93
+ end
94
+
95
+ # Passed in an aes encoded string and returns a cipher object
96
+ def decode_aes(str)
97
+ unpacked_data = Base64.decode64(str).unpack(ENCODING_FORMAT)
98
+ # Splat the data array apart
99
+ # nonce, auth_tag, encrypted_data = unpacked_data
100
+ CipherParams.new(*unpacked_data)
101
+ end
102
+ end
103
+
104
+ # Parse a JSON stream or file with encrypted values. Any values in the ".encrypted" key
105
+ # in the JSON document will be decrypted with the provided encryption key. If values
106
+ # were put into the ".encrypted" key manually and are not yet encrypted, they will be used
107
+ # as is without any decryption.
108
+ #
109
+ # @param [String, #read, Hash] path_or_stream path to a json/yaml file to load, an IO object, or a Hash (mostly for testing purposes)
110
+ # @param [String] encryption_key secret to use for encryption/decryption
111
+ #
112
+ # @note If no encryption key is passed, this will defautl to env var SECRET_KEYS_ENCRYPTION_KEY
113
+ # or (if that is empty) the value read from the file path in SECRET_KEYS_ENCRYPTION_KEY_FILE.
114
+ def initialize(path_or_stream, encryption_key = nil)
115
+ encryption_key = read_encryption_key(encryption_key)
116
+ update_secret(key: encryption_key)
117
+ path_or_stream = Pathname.new(path_or_stream) if path_or_stream.is_a?(String)
118
+ load_secrets!(path_or_stream)
119
+ # if no salt exists, create one.
120
+ update_secret(salt: SecureRandom.hex(8)) if @salt.nil?
121
+ super(@values)
122
+ end
123
+
124
+ # Convert the value into an actual Hash object.
125
+ #
126
+ # @return [Hash]
127
+ def to_h
128
+ @values
129
+ end
130
+ alias to_hash to_h
131
+
132
+ # Mark the key as being encrypted when the JSON is saved.
133
+ #
134
+ # @param [String] key key to mark as needing encryption
135
+ # @return [void]
136
+ def encrypt!(key)
137
+ @secret_keys << key
138
+ nil
139
+ end
140
+
141
+ # Mark the key as no longer being decrypted when the JSON is saved.
142
+ #
143
+ # @param [String] key key to mark as not needing encryption
144
+ # @return [void]
145
+ def decrypt!(key)
146
+ @secret_keys.delete(key)
147
+ nil
148
+ end
149
+
150
+ # Return true if the key is encrypted.
151
+ #
152
+ # @param [String] key key to check
153
+ # @return [Boolean]
154
+ def encrypted?(key)
155
+ @secret_keys.include?(key)
156
+ end
157
+
158
+ # Save the JSON to a file at the specified path. Encrypted values in the file
159
+ # will not be updated if the values have not changed (since each call uses a
160
+ # different initialization vector).
161
+ #
162
+ # @param [String] path Filepath to save to. Supports yaml and json format as the extension
163
+ # @param [Boolean] update: check to see if values have been changed before overwriting
164
+ # @return [void]
165
+ def save(path, update: true)
166
+ # create a copy of the encrypted hash for working on
167
+ encrypted = encrypted_hash
168
+
169
+ if File.exist?(path) && update
170
+ original_data = File.read(path)
171
+ original_hash = parse_data(original_data)
172
+ original_encrypted = original_hash[ENCRYPTED] if original_hash
173
+ # only check for unchanged keys if the original had encryption with the same key
174
+ if original_encrypted && encryption_key_matches?(original_encrypted[ENCRYPTION_KEY])
175
+ restore_unchanged_keys!(encrypted[ENCRYPTED], original_encrypted)
176
+ end
177
+ end
178
+
179
+ output = (yaml_file?(path) ? YAML.dump(encrypted) : "#{JSON.pretty_generate(encrypted)}#{$/}")
180
+ File.open(path, "w") do |file|
181
+ file.write(output)
182
+ end
183
+ nil
184
+ end
185
+
186
+ # Output the keys as a hash that matches the structure that can be loaded by the initalizer.
187
+ # Note that all encrypted values will be re-salted when they are encrypted.
188
+ #
189
+ # @return [Hash] An encrypted hash that can be saved/parsed by a new instance of {SecretKeys}
190
+ def encrypted_hash
191
+ raise EncryptionKeyError.new("Encryption key not specified") if @encryption_key.nil? || @encryption_key.empty?
192
+
193
+ hash = {}
194
+ encrypted = {}
195
+ @values.each do |key, value|
196
+ if @secret_keys.include?(key)
197
+ encrypted[key] = value
198
+ else
199
+ hash[key] = value
200
+ end
201
+ end
202
+ encrypted = {
203
+ SALT => @salt,
204
+ ENCRYPTION_KEY => key_dummy_value
205
+ }.merge(encrypt_values(encrypted))
206
+
207
+ hash[ENCRYPTED] = encrypted
208
+ hash
209
+ end
210
+
211
+ # Change the encryption key in the document. When saving later, this key will be used.
212
+ #
213
+ # @param [String] new_encryption_key encryption key to use for future {#save} calls
214
+ # @return [void]
215
+ def encryption_key=(new_encryption_key)
216
+ update_secret(key: new_encryption_key)
217
+ end
218
+
219
+ private
220
+
221
+ ENCRYPTED = ".encrypted"
222
+ ENCRYPTION_KEY = ".key"
223
+ SALT = ".salt"
224
+
225
+ # Used as a known dummy value for verifying we have the correct key
226
+ # DO NOT CHANGE!!!
227
+ KNOWN_DUMMY_VALUE = "SECRET_KEY"
228
+
229
+ KDF_ITERATIONS = 20_000
230
+ HASH_FUNC = "sha256"
231
+ KEY_LENGTH = 32
232
+
233
+ # Load the JSON data in a file path or stream into a hash, decrypting all the encrypted values.
234
+ #
235
+ # @return [void]
236
+ def load_secrets!(path_or_stream)
237
+ @secret_keys = Set.new
238
+ @values = {}
239
+
240
+ hash = nil
241
+ if path_or_stream.is_a?(Hash)
242
+ # HACK: Perform a marshal dump/load operation to get a deep copy of the hash.
243
+ # Otherwise, we can end up using destructive `#delete` operations and mess
244
+ # up deeply nested values for external code (esp. when loading key: .encrypted)
245
+ hash = Marshal.load(Marshal.dump(path_or_stream))
246
+ elsif path_or_stream
247
+ data = path_or_stream.read
248
+ hash = parse_data(data)
249
+ end
250
+ return if hash.nil? || hash.empty?
251
+
252
+ encrypted_values = hash.delete(ENCRYPTED)
253
+ if encrypted_values
254
+ file_key = encrypted_values.delete(ENCRYPTION_KEY)
255
+ update_secret(salt: encrypted_values.delete(SALT))
256
+
257
+ # Check that we are using the right key
258
+ if file_key && !encryption_key_matches?(file_key)
259
+ raise EncryptionKeyError.new("Incorrect encryption key")
260
+ end
261
+ @secret_keys = encrypted_values.keys
262
+ hash.merge!(decrypt_values(encrypted_values))
263
+ end
264
+
265
+ @values = hash
266
+ end
267
+
268
+ # Attempt to parse the file first using JSON and fallback to YAML
269
+ # @param [String] data file data to parse
270
+ # @return [Hash] data parsed to a hash
271
+ def parse_data(data)
272
+ JSON.parse(data)
273
+ rescue JSON::JSONError
274
+ YAML.safe_load(data)
275
+ end
276
+
277
+ # Recursively encrypt all values.
278
+ def encrypt_values(values)
279
+ if values.is_a?(Hash)
280
+ encrypted_hash = {}
281
+ values.keys.each do |key|
282
+ encrypted_hash[key.to_s] = encrypt_values(values[key])
283
+ end
284
+ encrypted_hash
285
+ elsif values.is_a?(Enumerable)
286
+ values.collect { |value| encrypt_values(value) }
287
+ else
288
+ encrypt_value(values)
289
+ end
290
+ end
291
+
292
+ # Recursively decrypt all values.
293
+ def decrypt_values(values)
294
+ if values.is_a?(Hash)
295
+ decrypted_hash = {}
296
+ values.each do |key, value|
297
+ decrypted_hash[key.to_s] = decrypt_values(value)
298
+ end
299
+ decrypted_hash
300
+ elsif values.is_a?(Enumerable)
301
+ values.collect { |value| decrypt_values(value) }
302
+ else
303
+ decrypt_value(values)
304
+ end
305
+ end
306
+
307
+ # Since the encrypted values include a salt, make sure we don't overwrite values in the stored
308
+ # documents when the decrypted values haven't changed since this would mess up any file history
309
+ # in a source code repository.
310
+ def restore_unchanged_keys!(new_hash, old_hash)
311
+ if new_hash.is_a?(Hash) && old_hash.is_a?(Hash)
312
+ new_hash.keys.each do |key|
313
+ new_value = new_hash[key]
314
+ old_value = old_hash[key]
315
+ next if new_value == old_value
316
+
317
+ if new_value.is_a?(Enumerable) && old_value.is_a?(Enumerable)
318
+ restore_unchanged_keys!(new_value, old_value)
319
+ elsif equal_encrypted_values?(new_value, old_value)
320
+ new_hash[key] = old_value
321
+ end
322
+ end
323
+ elsif new_hash.is_a?(Array) && old_hash.is_a?(Array)
324
+ new_hash.size.times do |i|
325
+ new_val = new_hash[i]
326
+ old_val = old_hash[i]
327
+ if new_val != old_val
328
+ if new_val.is_a?(Enumerable) && old_val.is_a?(Enumerable)
329
+ restore_unchanged_keys!(new_val, old_val)
330
+ elsif equal_encrypted_values?(new_val, old_val)
331
+ new_hash[i] = old_val
332
+ end
333
+ end
334
+ end
335
+ end
336
+ end
337
+
338
+ # Helper method to encrypt a value.
339
+ def encrypt_value(value)
340
+ self.class.encrypt(value, @secret_key)
341
+ end
342
+
343
+ # Helper method to decrypt a value.
344
+ def decrypt_value(encrypted_value)
345
+ self.class.decrypt(encrypted_value, @secret_key)
346
+ end
347
+
348
+ # Helper method to test if two values are both encrypted, but result in the same decrypted value.
349
+ def equal_encrypted_values?(value_1, value_2)
350
+ return true if value_1 == value_2
351
+
352
+ decrypt_val_1 = decrypt_value(value_1)
353
+ decrypt_val_2 = decrypt_value(value_2)
354
+ if decrypt_val_1 == decrypt_val_2
355
+ if value_1 == decrypt_val_1 || value_2 == decrypt_val_2
356
+ false
357
+ else
358
+ true
359
+ end
360
+ else
361
+ false
362
+ end
363
+ end
364
+
365
+ # Derive a key of given length from a password and salt value.
366
+ def derive_key(password, salt:, length:)
367
+ if defined?(OpenSSL::KDF)
368
+ OpenSSL::KDF.pbkdf2_hmac(password, salt: salt, iterations: KDF_ITERATIONS, length: length, hash: HASH_FUNC)
369
+ else
370
+ OpenSSL::PKCS5.pbkdf2_hmac(password, salt, KDF_ITERATIONS, length, HASH_FUNC)
371
+ end
372
+ end
373
+
374
+ # This is a
375
+ def key_dummy_value
376
+ encrypt_value(KNOWN_DUMMY_VALUE)
377
+ end
378
+
379
+ # Helper to check if our encryption key is correct
380
+ def encryption_key_matches?(encrypted_key)
381
+ decrypt_value(encrypted_key) == KNOWN_DUMMY_VALUE
382
+ rescue OpenSSL::Cipher::CipherError
383
+ # If the key fails to decrypt, then it cannot be correct
384
+ false
385
+ end
386
+
387
+ def yaml_file?(path)
388
+ ext = path.split(".").last.to_s.downcase
389
+ ext == "yaml" || ext == "yml"
390
+ end
391
+
392
+ # Update the secret key by updating the salt
393
+ #
394
+ # @param key: new encryption key
395
+ # @param salt: salt to use for secret
396
+ # @return [void]
397
+ def update_secret(key: nil, salt: nil)
398
+ @encryption_key = key unless key.nil? || key.empty?
399
+ @salt = salt unless salt.nil? || salt.empty?
400
+
401
+ # Only update the secret if encryption key and salt are present
402
+ if !@encryption_key.nil? && !@salt.nil?
403
+ # Convert the salt to raw byte string
404
+ salt_bytes = [@salt].pack("H*")
405
+ @secret_key = derive_key(@encryption_key, salt: salt_bytes, length: KEY_LENGTH)
406
+ end
407
+ # Don't accidentally return the secret, dammit
408
+ nil
409
+ end
410
+
411
+ # Logic to read an encryption key from environment variables if it is not explicitly supplied.
412
+ # If it isn't specified, the value will be read from the SECRET_KEYS_ENCRYPTION_KEY environment
413
+ # variable. Otherwise, it will be tried to read from the file specified by the
414
+ # SECRET_KEYS_ENCRYPTION_KEY_FILE environment variable.
415
+ def read_encryption_key(encryption_key)
416
+ return encryption_key if encryption_key && !encryption_key.empty?
417
+ encryption_key = ENV['SECRET_KEYS_ENCRYPTION_KEY']
418
+ return encryption_key if encryption_key && !encryption_key.empty?
419
+ encryption_key_file = ENV['SECRET_KEYS_ENCRYPTION_KEY_FILE']
420
+ if encryption_key_file && !encryption_key_file.empty? && File.exist?(encryption_key_file)
421
+ File.read(encryption_key_file).chomp
422
+ else
423
+ nil
424
+ end
425
+ end
426
+
427
+ end
@@ -0,0 +1,32 @@
1
+ Gem::Specification.new do |spec|
2
+ spec.name = "secret_keys"
3
+ spec.version = File.read(File.expand_path("../VERSION", __FILE__)).strip
4
+ spec.authors = ["Brian Durand", "Winston Durand"]
5
+ spec.email = ["bbdurand@gmail.com", "me@winstondurand.com"]
6
+
7
+ spec.summary = "Simple mechanism for loading JSON file with encrypted values."
8
+ spec.homepage = "https://github.com/bdurand/secret_keys"
9
+ spec.license = "MIT"
10
+
11
+ # Specify which files should be added to the gem when it is released.
12
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
13
+ ignore_files = %w[
14
+ .gitignore
15
+ .travis.yml
16
+ Appraisals
17
+ Gemfile
18
+ Gemfile.lock
19
+ Rakefile
20
+ gemfiles/
21
+ spec/
22
+ ]
23
+ spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do
24
+ `git ls-files -z`.split("\x0").reject { |f| ignore_files.any? { |path| f.start_with?(path) } }
25
+ end
26
+
27
+ spec.require_paths = ["lib"]
28
+ spec.bindir = "bin"
29
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
30
+
31
+ spec.add_development_dependency "bundler", "~>2.0"
32
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: secret_keys
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0.pre
5
+ platform: ruby
6
+ authors:
7
+ - Brian Durand
8
+ - Winston Durand
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2020-05-25 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: bundler
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - "~>"
19
+ - !ruby/object:Gem::Version
20
+ version: '2.0'
21
+ type: :development
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - "~>"
26
+ - !ruby/object:Gem::Version
27
+ version: '2.0'
28
+ description:
29
+ email:
30
+ - bbdurand@gmail.com
31
+ - me@winstondurand.com
32
+ executables:
33
+ - secret_keys
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - ".editorconfig"
38
+ - ".github/workflows/style.yml"
39
+ - ".github/workflows/test.yml"
40
+ - ".standard.yml"
41
+ - ".yardopts"
42
+ - CHANGE_LOG.md
43
+ - MIT_LICENSE.txt
44
+ - README.md
45
+ - VERSION
46
+ - bin/secret_keys
47
+ - lib/secret_keys.rb
48
+ - secret_keys.gemspec
49
+ homepage: https://github.com/bdurand/secret_keys
50
+ licenses:
51
+ - MIT
52
+ metadata: {}
53
+ post_install_message:
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">"
65
+ - !ruby/object:Gem::Version
66
+ version: 1.3.1
67
+ requirements: []
68
+ rubygems_version: 3.0.3
69
+ signing_key:
70
+ specification_version: 4
71
+ summary: Simple mechanism for loading JSON file with encrypted values.
72
+ test_files: []