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.
- checksums.yaml +4 -4
- data/MIT_LICENSE.txt +1 -1
- data/README.md +131 -27
- data/VERSION +1 -1
- data/bin/secret_keys +43 -109
- data/lib/secret_keys.rb +109 -185
- data/lib/secret_keys/cli.rb +339 -0
- data/lib/secret_keys/encryptor.rb +152 -0
- data/lib/secret_keys/version.rb +6 -0
- data/secret_keys.gemspec +7 -5
- metadata +12 -12
- data/.editorconfig +0 -8
- data/.github/workflows/style.yml +0 -11
- data/.github/workflows/test.yml +0 -42
- data/.standard.yml +0 -7
- data/.yardopts +0 -3
@@ -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
|