secret_keys 1.0.0.pre → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,339 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "io/console"
5
+
6
+ require_relative "../secret_keys.rb"
7
+
8
+ module SecretKeys::CLI
9
+ class Base
10
+ attr_reader :secret_key, :input
11
+
12
+ MAX_SUMMARY_LENGTH = 80
13
+
14
+ def initialize(argv)
15
+ # make sure we can only use stdin once
16
+ @stdin_used = false
17
+ @secrets = nil
18
+ parse_options(argv)
19
+ end
20
+
21
+ # Subclasses can override this method to parse additional options beyond the standard set.
22
+ def parse_additional_options(opts)
23
+ end
24
+
25
+ # @return [SecretKeys] the secrets
26
+ def secrets
27
+ @secrets ||= SecretKeys.new(@input, @secret_key)
28
+ end
29
+
30
+ # Subclasses should return the action name for the help banner
31
+ def action_name
32
+ "<encrypt|decrypt|read|edit>"
33
+ end
34
+
35
+ # Subclasses must implement this method to execute the logic.
36
+ def run!
37
+ raise NotImplementedError
38
+ end
39
+
40
+ # Return the output format.
41
+ def format
42
+ return @format if [:json, :yaml].include?(@format)
43
+ secrets.input_format
44
+ end
45
+
46
+ protected
47
+
48
+ def encrypted_file_contents
49
+ encrypted = secrets.encrypted_hash
50
+ string = (format == :yaml ? YAML.dump(encrypted) : JSON.pretty_generate(encrypted))
51
+ string << $/ unless string.end_with?($/) # ensure file ends with system dependent new line
52
+ string
53
+ end
54
+
55
+ private
56
+
57
+ def parse_options(argv)
58
+ @secret_key = nil
59
+ @format = nil
60
+
61
+ OptionParser.new do |opts|
62
+ opts.banner = "Usage: secret_keys #{action_name} [options] [--] [INFILE|-]"
63
+
64
+ opts.separator("\nGlobal options:")
65
+
66
+ secret_docs = split(<<~HELP)
67
+ Encryption key used to encrypt strings in the file.
68
+ This value can also be passed in the SECRET_KEYS_ENCRYPTION_KEY environment variable or via STDIN by specifying '-'.
69
+ HELP
70
+ opts.on("-s", "--secret-key=SECRET", String, *secret_docs) do |value|
71
+ raise ArgumentError, "You have already passed in the secret key" unless @secret_key.nil?
72
+ @secret_key = get_secret_key(value)
73
+ end
74
+
75
+ secret_file_docs = split(<<~HELP)
76
+ Path to a file that contains the encryption key.
77
+ This value can also be passed in the SECRET_KEYS_ENCRYPTION_KEY_FILE environment variable.
78
+ HELP
79
+ opts.on("--secret-key-file=PATH", String, *secret_file_docs) do |value|
80
+ raise ArgumentError, "You have already passed in the secret key" unless @secret_key.nil?
81
+ @secret_key = File.read(value).chomp
82
+ end
83
+
84
+ opts.on("-f", "--format FORMAT", [:json, :yaml], "Set the output format. By default this will be the same as the input format.") do |value|
85
+ @format = value
86
+ end
87
+
88
+ opts.on("-h", "--help", "Prints this help") do
89
+ puts opts.help
90
+ exit
91
+ end
92
+
93
+ parse_additional_options(opts)
94
+ end.order!(argv)
95
+
96
+ @input = argv.shift
97
+ if @input.nil? || @input == "-"
98
+ can_i_haz_stdin!
99
+ @input = $stdin
100
+ end
101
+
102
+ raise ArgumentError.new("Too many arguments") unless argv.empty?
103
+ end
104
+
105
+ def get_secret_key(value)
106
+ if value == "-"
107
+ can_i_haz_stdin!
108
+ if $stdin.tty?
109
+ $stdin.getpass("Secret key: ")
110
+ else
111
+ $stdin.gets.chomp
112
+ end
113
+ else
114
+ value
115
+ end
116
+ end
117
+
118
+ # @return [Array] array of strings from docstring, split at length
119
+ def split(docstring, length: MAX_SUMMARY_LENGTH)
120
+ docstring = docstring.strip
121
+ docstring.gsub!(/\s+/, " ")
122
+ docstring.scan(/(.{1,#{length}})(?:\s+|\z)/).flatten
123
+ end
124
+
125
+ # Mark that you want to use stdin and raise an exception if it's already been used.
126
+ def can_i_haz_stdin!
127
+ raise ArgumentError, "stdin (-) cannot be specified multiple times" if @stdin_used
128
+ @stdin_used = true
129
+ end
130
+
131
+ # @param parent data structure to recurse over
132
+ # @param key to access
133
+ # @yield context of key and parent
134
+ # @yieldparam parent the parent object
135
+ # @yieldparam key the last child node
136
+ def access_key(parent, key, write: false)
137
+ splits = key.split(".")
138
+ last_key = splits.length - 1
139
+ splits.each_with_index do |curr, idx|
140
+ if parent.is_a?(Array)
141
+ k = curr.to_i
142
+ raise ArgumentError, "Array index must be a positive number" if curr != k.to_s || k < 0
143
+ elsif parent.is_a?(Hash) || parent.is_a?(SecretKeys)
144
+ k = curr
145
+ else
146
+ raise ArgumentError, "No such key: #{key.inspect}"
147
+ end
148
+
149
+ return yield(parent, k) if idx == last_key
150
+
151
+ if parent[k].nil?
152
+ return nil unless write
153
+ parent[k] = {}
154
+ end
155
+ parent = parent[k]
156
+ end
157
+ end
158
+ end
159
+
160
+ class Init < Base
161
+ def action_name
162
+ "init"
163
+ end
164
+
165
+ def run!
166
+ @secrets = SecretKeys.new({}, secret_key)
167
+ if input.is_a?(String)
168
+ if File.exist?(input)
169
+ STDERR.puts "Error: Cannot init preexisting file '#{input}'"
170
+ STDERR.puts "You may want to try calling `secret_keys encrypt/edit` instead"
171
+ exit 1
172
+ end
173
+
174
+ File.write(input, encrypted_file_contents)
175
+ else
176
+ $stdout.write(encrypted_file_contents)
177
+ end
178
+ end
179
+ end
180
+
181
+ class Encrypt < Base
182
+ def action_name
183
+ "encrypt"
184
+ end
185
+
186
+ def parse_additional_options(opts)
187
+ opts.separator("\nEncrypt options:")
188
+
189
+ @new_secret_key = nil
190
+ opts.on("--new-secret-key=NEW_SECRET", String, *split(<<~DOC)) do |value|
191
+ Encryption key used to encrypt strings in the file on output.
192
+ This option can be used to change the encryption key. If set to '-', read from STDIN.
193
+ DOC
194
+ @new_secret_key = get_secret_key(value)
195
+ end
196
+
197
+ @in_place = false
198
+ opts.on("-i", "--in-place", "Update the input file instead of writing to stdout.") do |value|
199
+ @in_place = true
200
+ end
201
+
202
+ @encrypt_all = false
203
+ opts.on("--encrypt-all", "Encrypt all keys in the file") do |value|
204
+ @encrypt_all = value
205
+ end
206
+ end
207
+
208
+ def run!
209
+ if @new_secret_key && !@new_secret_key.empty?
210
+ secrets.encryption_key = @new_secret_key
211
+ end
212
+
213
+ if @encrypt_all
214
+ secrets.each_key do |key|
215
+ secrets.encrypt!(key)
216
+ end
217
+ end
218
+
219
+ if @in_place
220
+ raise ArgumentError, "Cannot perform in place editing on streams" unless @input.is_a?(String)
221
+ # make sure we read the file **before** writing to it.
222
+ contents = encrypted_file_contents
223
+ File.open(@input, "w") do |file|
224
+ file.write(contents)
225
+ end
226
+ else
227
+ $stdout.write(encrypted_file_contents)
228
+ $stdout.flush
229
+ end
230
+ end
231
+ end
232
+
233
+ class Decrypt < Base
234
+ def action_name
235
+ "decrypt"
236
+ end
237
+
238
+ def run!
239
+ decrypted = secrets.to_h
240
+ string = (format == :yaml ? YAML.dump(decrypted) : JSON.pretty_generate(decrypted))
241
+ string << $/ unless string.end_with?($/) # ensure file ends with system dependent new line
242
+ $stdout.write(string)
243
+ $stdout.flush
244
+ end
245
+ end
246
+
247
+ class Read < Base
248
+ attr_reader :key
249
+
250
+ def action_name
251
+ "read"
252
+ end
253
+
254
+ def parse_additional_options(opts)
255
+ opts.separator("\n Read options:")
256
+ @key = nil
257
+ opts.on("-k", "--key KEY", String, "Key from the file to output. You can use dot notation to read a nested key.") do |value|
258
+ @key = value
259
+ end
260
+ end
261
+
262
+ def run!
263
+ raise ArgumentError.new("key is required") if @key.nil? || @key.empty?
264
+ val = secrets.to_h
265
+ val = access_key(val, @key) { |parent, key| parent[key] }
266
+ $stdout.write(val)
267
+ $stdout.flush
268
+ end
269
+ end
270
+
271
+ class Edit < Encrypt
272
+ attr_reader :actions
273
+
274
+ def action_name
275
+ "edit"
276
+ end
277
+
278
+ def parse_additional_options(opts)
279
+ opts.separator("\nEdit options:")
280
+
281
+ @actions = []
282
+ set_encrypted_docs = split(<<~HELP)
283
+ Set an encrypted value in the file. You can use dot notation to set a nested value.
284
+ If no VALUE is specified, the key will be moved to the encrypted keys while keeping any existing value.
285
+ HELP
286
+ opts.on("-e", "--set-encrypted KEY[=VALUE]", String, *set_encrypted_docs) do |value|
287
+ key, val = value.split("=", 2)
288
+ @actions << [:encrypt, key, val]
289
+ end
290
+
291
+ set_decrypted_docs = split(<<~HELP)
292
+ Set a plain text value in the file. You can use dot notation to set a nested value. If no VALUE is specified,
293
+ the key will be moved to the plain text keys while keeping any existing value.
294
+ HELP
295
+ opts.on("-d", "--set-decrypted KEY[=VALUE]", String, *set_decrypted_docs) do |value|
296
+ key, val = value.split("=", 2)
297
+ @actions << [:decrypt, key, val]
298
+ end
299
+
300
+ opts.on("-r", "--remove KEY", String, "Remove a key from the file. You can use dot notation to remove a nested value.") do |value|
301
+ @actions << [:remove, value, nil]
302
+ end
303
+
304
+ super
305
+ end
306
+
307
+ def run!
308
+ @actions.each do |action, key, value|
309
+ raise ArgumentError.new("cannot set a key beginning with dot") if key.start_with?(".")
310
+ case action
311
+ when :encrypt
312
+ secrets.encrypt!(key.split(".").first)
313
+ unless value.nil?
314
+ access_key(secrets, key, write: true) do |parent, child|
315
+ parent[child] = value
316
+ end
317
+ end
318
+ when :decrypt
319
+ secrets.decrypt!(key.split(".").first)
320
+ unless value.nil?
321
+ access_key(secrets, key, write: true) do |parent, child|
322
+ parent[child] = value
323
+ end
324
+ end
325
+ when :remove
326
+ access_key(secrets, key) do |parent, child|
327
+ if parent.is_a?(Array)
328
+ parent.delete_at(child)
329
+ else
330
+ parent.delete(child)
331
+ end
332
+ end
333
+ end
334
+ end
335
+
336
+ super
337
+ end
338
+ end
339
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "openssl"
5
+ require "base64"
6
+
7
+ # Encyption helper for encrypting and decrypting values using AES-256-GCM and returning
8
+ # as Base64 encoded strings. The encrypted values also include a prefix that can be used
9
+ # to detect if a string is an encrypted value.
10
+ class SecretKeys::Encryptor
11
+ # format: <nonce:12>, <auth_tag:16>, <data:*>
12
+ ENCODING_FORMAT = "a12 a16 a*"
13
+ ENCRYPTED_PREFIX = "$AES$:"
14
+ CIPHER = "aes-256-gcm"
15
+ KDF_ITERATIONS = 20_000
16
+ HASH_FUNC = "sha256"
17
+ KEY_LENGTH = 32
18
+
19
+ # Valid salts are hexencoded strings
20
+ SALT_MATCHER = /\A(\h\h)+\z/.freeze
21
+
22
+ class << self
23
+ # Create an Encryptor from a password and salt. This is a shortcut for generating an Encryptor
24
+ # with a 32 byte encryption key. The key will be derived from the password and salt.
25
+ # @param [String] password secret used to encrypt the data
26
+ # @param [String] salt random hex-encoded byte array for key derivation
27
+ # @return [SecretKeys::Encryptor] a new encryptor with key derived from password and salt
28
+ def from_password(password, salt)
29
+ raise ArgumentError, "Password must be present" if password.nil? || password.empty?
30
+ raise ArgumentError, "Salt must be a hex encoded value" if salt.nil? || !SALT_MATCHER.match?(salt)
31
+ # Convert the salt to raw byte string
32
+ salt_bytes = [salt].pack("H*")
33
+ derived_key = derive_key(password, salt: salt_bytes)
34
+
35
+ new(derived_key)
36
+ end
37
+
38
+ # Detect of the value is a string that was encrypted by this library.
39
+ def encrypted?(value)
40
+ value.is_a?(String) && value.start_with?(ENCRYPTED_PREFIX)
41
+ end
42
+
43
+ # Derive a key of given length from a password and salt value.
44
+ def derive_key(password, salt:, length: KEY_LENGTH, iterations: KDF_ITERATIONS, hash: HASH_FUNC)
45
+ if defined?(OpenSSL::KDF)
46
+ OpenSSL::KDF.pbkdf2_hmac(password, salt: salt, iterations: iterations, length: length, hash: hash)
47
+ else
48
+ # Ruby 2.4 compatibility
49
+ OpenSSL::PKCS5.pbkdf2_hmac(password, salt, iterations, length, hash)
50
+ end
51
+ end
52
+
53
+ # @return [String] hex encoded random bytes
54
+ def random_salt
55
+ SecureRandom.hex(16)
56
+ end
57
+ end
58
+
59
+ # @param [String] raw_key the key directly passed into the encrypt/decrypt functions. This must be exactly {KEY_LENGTH} bytes long.
60
+ def initialize(raw_key)
61
+ raise ArgumentError, "key must be #{KEY_LENGTH} bytes long" unless raw_key.bytesize == KEY_LENGTH
62
+ @derived_key = raw_key
63
+ end
64
+
65
+ # Encrypt a string with the encryption key. Encrypted values are also salted so
66
+ # calling this function multiple times will result in different values. Only strings
67
+ # can be encrypted. Any other object type will be return the value passed in.
68
+ #
69
+ # @param [String] str string to encrypt (assumes UTF-8)
70
+ # @return [String] Base64 encoded encrypted string with all aes parameters
71
+ def encrypt(str)
72
+ return str unless str.is_a?(String)
73
+ return "" if str == ""
74
+
75
+ cipher = OpenSSL::Cipher.new(CIPHER).encrypt
76
+
77
+ # Technically, this is a "bad" way to do things since we could theoretically
78
+ # get a repeat nonce, compromising the algorithm. That said, it should be safe
79
+ # from repeats as long as we don't use this key for more than 2^32 encryptions
80
+ # so... rotate your keys/salt ever 4 billion encryption calls
81
+ nonce = cipher.random_iv
82
+ cipher.key = @derived_key
83
+ cipher.auth_data = ""
84
+
85
+ # Make sure the string is encoded as UTF-8. JSON/YAML only support string types
86
+ # anyways, so if you passed in binary data, it was gonna fail anyways. This ensures
87
+ # that we can easily decode the string later. If you have UTF-16 or something, deal with it.
88
+ utf8_str = str.encode(Encoding::UTF_8)
89
+ encrypted_data = cipher.update(utf8_str) + cipher.final
90
+ auth_tag = cipher.auth_tag
91
+
92
+ params = CipherParams.new(nonce, auth_tag, encrypted_data)
93
+
94
+ encode_aes(params).prepend(ENCRYPTED_PREFIX)
95
+ end
96
+
97
+ # Decrypt a string with the encryption key. If the value is not a string or it was
98
+ # not encrypted with the encryption key, the value itself will be returned.
99
+ #
100
+ # @param [String] encrypted_str Base64 encoded encrypted string with aes params (from {#encrypt})
101
+ # @return [String] decrypted string value
102
+ # @raise [OpenSSL::Cipher::CipherError] there is something wrong with the encoded data (usually incorrect key)
103
+ def decrypt(encrypted_str)
104
+ return encrypted_str unless self.class.encrypted?(encrypted_str)
105
+
106
+ decrypt_str = encrypted_str[ENCRYPTED_PREFIX.length..-1]
107
+ params = decode_aes(decrypt_str)
108
+
109
+ cipher = OpenSSL::Cipher.new(CIPHER).decrypt
110
+
111
+ cipher.key = @derived_key
112
+ cipher.iv = params.nonce
113
+ cipher.auth_tag = params.auth_tag
114
+ cipher.auth_data = ""
115
+
116
+ decoded_str = cipher.update(params.data) + cipher.final
117
+
118
+ # force to utf-8 encoding. We already ensured this when we encoded in the first place
119
+ decoded_str.force_encoding(Encoding::UTF_8)
120
+ end
121
+
122
+ def encrypted?(value)
123
+ self.class.encrypted?(value)
124
+ end
125
+
126
+ def inspect
127
+ obj_id = object_id.to_s(16).rjust(16, "0")
128
+ "#<#{self.class.name}:0x#{obj_id}>"
129
+ end
130
+
131
+ private
132
+
133
+ # Basic struct to contain nonce, auth_tag, and data for passing around. Thought
134
+ # it was better than just passing an Array with positional params.
135
+ # @private
136
+ CipherParams = Struct.new(:nonce, :auth_tag, :data)
137
+
138
+ # Receive a cipher object (initialized with key) and data
139
+ def encode_aes(params)
140
+ encoded = params.values.pack(ENCODING_FORMAT)
141
+ # encode base64 and get rid of trailing newline and unnecessary =
142
+ Base64.encode64(encoded).chomp.tr("=", "")
143
+ end
144
+
145
+ # Passed in an aes encoded string and returns a cipher object
146
+ def decode_aes(str)
147
+ unpacked_data = Base64.decode64(str).unpack(ENCODING_FORMAT)
148
+ # Splat the data array apart
149
+ # nonce, auth_tag, encrypted_data = unpacked_data
150
+ CipherParams.new(*unpacked_data)
151
+ end
152
+ end