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.
- 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
|