secret_keys 1.0.0.pre → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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