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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db4d05a36d5a69f053949e6be11cdedde1203d87d57e41b4609ffbea7118e3b6
4
- data.tar.gz: f753189aaed3cae64ef13029a47ee68616a7ca95d309f2607c5427450f7ba63c
3
+ metadata.gz: d291d004402b3197d72a0210d4586770c7f63e9f1baf70dfb14c5d6d848b52a5
4
+ data.tar.gz: bbd6e6d3f8e625a31a0502c5099004e9ebbe278d21c39f674ed8a4d0d4c822b5
5
5
  SHA512:
6
- metadata.gz: 73b58cba572869c1e9a399620ee56b6acdc5b288a1f95337e611bd84ae7231417bbeeaaf3217bc28daec006cec304c79ed62ad6c86ee5acd25513689bfcd8f22
7
- data.tar.gz: 9f0c00479cbbfad0d14695f6b446c7427e74865a47f9f05d26e16ad45179405bf82452fc54b283ff73433d93f268998b148eac050fb0583ea0ab58c98f2cf26e
6
+ metadata.gz: 1d1dae102dd23919a34187b5d6c1c89c8722635aa3937c56777b9dca19cf6a8a765b291bb3c33c792d4c258dc44eb9cf2aa858559171cacc716e73da8d29b24e
7
+ data.tar.gz: 527a4ab4a67eb63424dae4a284fe9a398efe2d98cf405184eaacfcfbd4f51140d0ffcf389ee35fa3d31202e14aab85ea88df886b3e782f933414fd6ae3de5cd0
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2020 Brian Durand
3
+ Copyright (c) 2020 Brian Durand, Winston Durand
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,10 +1,14 @@
1
1
  # SecretKeys
2
2
 
3
+ [![specs](https://github.com/bdurand/secret_keys/workflows/Run%20tests/badge.svg)](https://github.com/bdurand/secret_keys/actions?query=branch%3Amaster)
4
+ [![code style](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/testdouble/standard)
5
+ [![gem version](https://badge.fury.io/rb/secret_keys.svg)](https://badge.fury.io/rb/secret_keys)
6
+
3
7
  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
8
 
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.
9
+ The main advantage offered by this gem is that it stores the files in standard JSON or YAML format and can store both encrypted and non-encrypted values side-by-side, easily tracking 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
10
 
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.
11
+ Encrypted values are stored using AES-256-GCM, and the key is derived from your password secret and a generated salt using PBKDF2. All security primitives are provided by OpenSSL, based on recommendations put forth in the [libsodium](https://doc.libsodium.org/secret-key_cryptography/aead/aes-256-gcm) crypto suite.
8
12
 
9
13
  ## Usage
10
14
 
@@ -17,10 +21,12 @@ secrets = SecretKeys.new("/path/to/file.json", "mysecretkey")
17
21
  or a stream
18
22
 
19
23
  ```ruby
20
- secrets = SecretKeys.new(File.open("/path/to/file.json"), "mysecretkey")
24
+ stream = File.open("/path/to/file.json")
25
+ secrets = SecretKeys.new(stream, "mysecretkey")
26
+ stream.close
21
27
  ```
22
28
 
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.
29
+ If you don't supply the encryption key in the constructor, by it will be read from the `SECRET_KEYS_ENCRYPTION_KEY` environment variable. If that value is not present, then it will attempt 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 there is no explicitly passed secret and no `ENV` variables.
24
30
 
25
31
  The `SecretKeys` object delegates to `Hash` and can be treated as a hash for most purposes.
26
32
 
@@ -28,7 +34,7 @@ The `SecretKeys` object delegates to `Hash` and can be treated as a hash for mos
28
34
  password = secrets["password"]
29
35
  ```
30
36
 
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.
37
+ 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 or YAML file.
32
38
 
33
39
  ```ruby
34
40
  # api_key is plaintext by default
@@ -39,23 +45,31 @@ secrets.encrypt!("api_key")
39
45
 
40
46
  # now, when we save, the value for api_key is encrypted
41
47
  secrets.save("/path/to/file.json")
48
+
49
+ # or get a Hash with the encrypted data to handle it yourself
50
+ secrets.encrypted_hash
42
51
  ```
43
52
 
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.
53
+ 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. All keys must be strings.
45
54
 
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.
55
+ **Only string values are encrypted**. The encryption is recursive, so all strings in an array or hash in the encrypted keys will be encrypted. See the example below.
47
56
 
48
- ```json
57
+ ```javascript
49
58
  {
50
59
  ".encrypted": {
51
60
  "enc_key1": {
52
- "num": 1,
53
- "rec": ["<encrypted-val>", true],
61
+ "num": 1, // primitives are not encrypted
62
+ "null_value": null, // null is not encrypted
63
+ "rec": [
64
+ "<encrypted-val>", // we recurse through the array to encrypt its strings
65
+ true // booleans aren't encrypted either
66
+ ],
54
67
  "thing": "<encrypted-val>"
55
68
  },
56
69
  "enc_key2": "<encrypted-val>"
57
70
  },
58
- "unenc_key": "plaintext"
71
+ "unenc_key": "plaintext",
72
+ "other_plaintext": "See, you can read my contents!"
59
73
  }
60
74
  ```
61
75
 
@@ -63,52 +77,107 @@ Only string values can be encrypted. The encryption is recusive, so all strings
63
77
 
64
78
  You can use the `secret_keys` command line tool to manage your JSON files.
65
79
 
66
- You can initialize a new file with the encrypt command.
80
+ ```console
81
+ $ secret_keys help
82
+ Usage: secret_keys <command> ...
83
+
84
+ Commands:
85
+ encrypt Encrypt a file
86
+ decrypt Decrypt a file
87
+ read Read the value of one key in a file
88
+ edit Change which values are encrypted, the file's encryption key, delete/add keys, etc.
89
+ init Initialize an empty secrets file
90
+
91
+ help Get help for a command
92
+ ```
93
+
94
+ You can initialize a new file with the init command.
95
+
96
+ ```bash
97
+ secret_keys init --secret=mysecret /path/to/new/file.json
98
+ ```
99
+
100
+ Or add the encryption section to an existing file.
67
101
 
68
102
  ```bash
69
- secret_keys encrypt --key mysecret /path/to/file.json
103
+ secret_keys encrypt --secret=mysecret --in-place /path/to/file.json
70
104
  ```
71
105
 
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.
106
+ You can also specify the path to a file where the secret is stored with `--secret-file`. If you don't specify the `--secret` or `--secret-file` argument, the secret will be read from the `SECRET_KEYS_ENCRYPTION_KEY` or `SECRET_KEYS_ENCRYPTION_KEY_FILE` environment variable.
107
+
108
+ You can also specify to read the secret from STDIN with `--secret=-`.
109
+
110
+ ```bash
111
+ # reading from stdin
112
+ $ secret_keys encrypt --secret=- data.json
113
+ Secret Key: <hidden password input>
114
+
115
+ # or you can pipe in the secret
116
+ $ echo "my_secret" | secret_keys encrypt --secret=- data.json
117
+ ```
73
118
 
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.
119
+ You can then use your favorite text editor to edit the values in the file and putting any keys you want encrypted in the `".encrypted"` section. When you are done, you can run the same command again to encrypt all new keys in the file. The default behaviour is to output the file to STDOUT, or you can rewrite the file in place by passing `--in-place`.
75
120
 
76
- You can add or modify keys through the command line as well.
121
+ Finally, calling encrypt with `--encrypt-all` will encrypt all keys in a file. You can use this to encrypt all the values in an existing JSON or YAML file.
77
122
 
78
123
  ```bash
79
- # add an encrypted key
80
- secret_keys encrypt --key mysecret --set password /path/to/file.json
124
+ secret_keys encrypt -s mysecret --encrypt-all --in-place data.json
125
+ ```
126
+
127
+ You can also add or modify keys through the command line using `--set-encrypted` or `-e` for short. You can also use "dot syntax" to address nested keys, for example `aws.client_secret` addresses `{"aws":{"client_secret": <value>}}`
128
+
129
+ ```console
130
+ # mark individual keys for encryption
131
+ $ secret_keys edit -s mysecret --set-encrypted password -e other_password /path/to/file.json
132
+ { ... }
81
133
 
82
134
  # add an encrypted key with a value
83
- secret_keys encrypt --key mysecret --set password=value /path/to/file.json
135
+ $ secret_keys edit -s mysecret --set-encrypted password=value /path/to/file.json
136
+ { ... }
84
137
 
85
- # encrypt all keys in the file
86
- secret_keys encrypt --key mysecret --all /path/to/file.json
138
+ # edit nested keys (assumes hashes by default)
139
+ # nested keys are split on `.` dots
140
+ $ secret_keys edit -s mysecret -e aws.secret=password data.json
141
+ {
142
+ ".encrypted": {
143
+ "aws": {
144
+ "secret": "<encrypted-value>"
145
+ },
146
+ ...
147
+ }
148
+ }
87
149
  ```
88
150
 
89
- You can also decrypt or delete keys.
151
+ You can also decrypt keys by moving them to the plain text section of the file (`--set-decrypted` or `-d`) or remove them altogether (`--remove` or `-r`).
90
152
 
91
153
  ```bash
92
- secret_keys encrypt --key mysecret --decrypt username --delete password /path/to/file.json
154
+ secret_keys edit -s mysecret --set-decrypted username --remove password /path/to/file.json
93
155
  ```
94
156
 
95
157
  You can change the encryption key used in the file.
96
158
 
97
159
  ```bash
98
- secret_keys encrypt --key mysecret --new-key newsecret /path/to/file.json
160
+ secret_keys encrypt -s mysecret --new-secret-key newsecret /path/to/file.json
99
161
  ```
100
162
 
101
163
  Finally, you can print the unencrypted file to STDOUT.
102
164
 
103
165
  ```bash
104
- secret_keys decrypt --key mysecret /path/to/file.json
166
+ # print the decrypted file to stdout
167
+ secret_keys decrypt --secret mysecret /path/to/file.json
168
+
169
+ # Explicitly output as JSON
170
+ secret_keys decrypt --secret mysecret --format json /path/to/file.json
171
+
172
+ # Output the data as YAML
173
+ secret_keys decrypt --secret mysecret --format yaml /path/to/file.json
105
174
  ```
106
175
 
107
176
  ## File Format
108
177
 
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.
178
+ The data can be stored in a plain old JSON or YAML file. Any unencrypted keys will appear under the special `".encrypted"` key in the hash. A check value (to validate you are using the correct encryption key) is stored under `".key"`. Finally, there is also the `".salt"` which was used for key derivation.
110
179
 
111
- In this example, `key_1` is stored in plain text while `key_2` has been encrypted.
180
+ In this example, `not_encrypted` is stored in plain text while `foo` has been encrypted.
112
181
 
113
182
  ```json
114
183
  {
@@ -124,3 +193,38 @@ In this example, `key_1` is stored in plain text while `key_2` has been encrypte
124
193
  "not_encrypted": "plain text value"
125
194
  }
126
195
  ```
196
+
197
+ ## SecretKeys::Encryptor
198
+
199
+ This library also comes with a generic encryption tool that can be used on its own as a generic tool for encrypting strings with AES-256-GCM encryption.
200
+
201
+ ```ruby
202
+ secret = "mysecret"
203
+ # The salt is used to generate an encryption key from the secret.
204
+ # You do not need to salt individual values when encrypting them.
205
+ # This will be done by the encryption algorithm itself.
206
+ # The salt must be a hex encoded byte array.
207
+ salt = "deadbeef"
208
+
209
+ encryptor = SecretKeys::Encryptor.from_passowrd(secret, salt)
210
+
211
+ encrypted = encryptor.encrypt("foobar") # => "$AES$:345kjwertE345E..."
212
+ encryptor.decrypt(encrypted) # => "foobar"
213
+ encryptor.decrypt("foobar") # => "foobar"
214
+
215
+ # If the data is corrupted/tampered with, decryption will raise an error.
216
+ # This can also be caused by using the wrong key.
217
+ begin
218
+ encryptor.decrypt("$AES$:malformed/corrupted data")
219
+ rescue OpenSSL::Cipher::CipherError
220
+ puts "Bad data/encryption key"
221
+ end
222
+
223
+ # You can also check if a value looks like an encrypted string.
224
+ SecretKeys::Encryptor.encrypted?("foobar") # => false
225
+ SecretKeys::Encryptor.encrypted?(encrypted) # => true
226
+ ```
227
+
228
+ ## Versioning
229
+
230
+ This code aims to be compliant with [Semantic Verioning 2.0](https://semver.org/). If there is ever a need to change file encryption parameters, those changes will be released as a new major version. Just to be clear, we do not anticipate needing to change these parameters.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.0.pre
1
+ 1.0.0
@@ -1,9 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- # TODO this command tool will be rewritten with better, more consistent options.
4
-
5
- require 'optparse'
6
- require_relative "../lib/secret_keys"
3
+ require_relative "../lib/secret_keys/cli"
7
4
 
8
5
  # Load enironment variables from .env file in current working directory if available.
9
6
  if File.exist?(File.expand_path(".env"))
@@ -14,115 +11,52 @@ if File.exist?(File.expand_path(".env"))
14
11
  end
15
12
  end
16
13
 
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
14
+ COMMANDS = {
15
+ "encrypt" => SecretKeys::CLI::Encrypt,
16
+ "decrypt" => SecretKeys::CLI::Decrypt,
17
+ "read" => SecretKeys::CLI::Read,
18
+ "edit" => SecretKeys::CLI::Edit,
19
+ "init" => SecretKeys::CLI::Init
20
+ }.freeze
21
+
22
+ argv = ARGV
23
+ action = argv.shift
24
+ command_class = COMMANDS[action]
25
+ unless command_class
26
+ if action == "help" || action == "--help"
27
+ if argv.empty?
28
+ puts <<~HELP
29
+ Usage: secret_keys <command> ...
30
+ version #{SecretKeys::VERSION}
31
+
32
+ Commands:
33
+ encrypt Encrypt a file
34
+ decrypt Decrypt a file
35
+ read Read the value of one key in a file
36
+ edit Change which values are encrypted, the file's encryption key, delete/add keys, etc.
37
+ init Initialize an empty secrets file
38
+
39
+ help Get help for a command
40
+ HELP
41
+ elsif argv.one? && COMMANDS.include?(argv.first)
42
+ COMMANDS[argv.first].new(["--help"])
43
+ else
44
+ STDERR.puts "Unknown help for #{argv.inspect}."
45
+ exit 1
46
+ end
53
47
 
54
- opts.on("--help", "Prints this help") do
55
- puts opts.help
56
- exit
48
+ exit 0
49
+ else
50
+ STDERR.puts "Unknow action #{action.inspect}; must be one of #{COMMANDS.keys.join(', ')}"
51
+ STDERR.puts "Run secret_keys --help for more info"
57
52
  end
58
- end
59
- parser.parse!(options)
60
-
61
- unless ["encrypt", "decrypt"].include?(action)
62
- STDERR.puts parser.banner
63
53
  exit 1
64
54
  end
65
55
 
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
56
+ begin
57
+ command = command_class.new(argv)
58
+ command.run!
59
+ rescue ArgumentError => e
60
+ STDERR.puts e.message
127
61
  exit 1
128
62
  end
@@ -3,125 +3,42 @@
3
3
  require "openssl"
4
4
  require "json"
5
5
  require "yaml"
6
- require "securerandom"
7
6
  require "delegate"
8
7
  require "set"
9
8
  require "pathname"
10
- require "base64"
11
9
 
12
10
  # Load a JSON file with encrypted values. This value can be used as a hash.
13
11
  class SecretKeys < DelegateClass(Hash)
12
+ # Error if the provided encryption key is invalid for this file
14
13
  class EncryptionKeyError < ArgumentError; end
15
14
 
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
15
+ # Error if the crypto version specified in the file is unsupported
16
+ class VersionError < StandardError; end
94
17
 
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
18
+ # Parse a JSON or YAML stream or file with encrypted values. Any values in the ".encrypted" key
19
+ # in the document will be decrypted with the provided encryption key. If values
106
20
  # were put into the ".encrypted" key manually and are not yet encrypted, they will be used
107
21
  # as is without any decryption.
108
22
  #
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)
23
+ # @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
24
  # @param [String] encryption_key secret to use for encryption/decryption
111
25
  #
112
26
  # @note If no encryption key is passed, this will defautl to env var SECRET_KEYS_ENCRYPTION_KEY
113
27
  # or (if that is empty) the value read from the file path in SECRET_KEYS_ENCRYPTION_KEY_FILE.
114
28
  def initialize(path_or_stream, encryption_key = nil)
29
+ @encryption_key = nil
30
+ @salt = nil
31
+ @format = :json
32
+
115
33
  encryption_key = read_encryption_key(encryption_key)
116
34
  update_secret(key: encryption_key)
117
35
  path_or_stream = Pathname.new(path_or_stream) if path_or_stream.is_a?(String)
118
36
  load_secrets!(path_or_stream)
119
- # if no salt exists, create one.
120
- update_secret(salt: SecureRandom.hex(8)) if @salt.nil?
37
+
121
38
  super(@values)
122
39
  end
123
40
 
124
- # Convert the value into an actual Hash object.
41
+ # Convert into an actual Hash object.
125
42
  #
126
43
  # @return [Hash]
127
44
  def to_h
@@ -155,28 +72,30 @@ class SecretKeys < DelegateClass(Hash)
155
72
  @secret_keys.include?(key)
156
73
  end
157
74
 
158
- # Save the JSON to a file at the specified path. Encrypted values in the file
75
+ # Save the encrypted hash to a file at the specified path. Encrypted values in an existing file
159
76
  # will not be updated if the values have not changed (since each call uses a
160
- # different initialization vector).
77
+ # different initialization vector). This can be helpful if you have your secrets in source
78
+ # control so that only changed keys will actually be changed in the file when it is updated.
161
79
  #
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
80
+ # @param [String, Pathname] path path of the file to save. If the file exists, only changed values will be updated.
81
+ # @param [String, Symbol] format: output format (YAML or JSON) to use. This will default based on the extension on the file path or the format originally used
164
82
  # @return [void]
165
- def save(path, update: true)
83
+ def save(path, format: nil)
166
84
  # create a copy of the encrypted hash for working on
167
85
  encrypted = encrypted_hash
168
86
 
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)
87
+ if format.nil?
88
+ if yaml_file?(path)
89
+ format = :yaml
90
+ elsif json_file?(path)
91
+ format = :json
176
92
  end
177
93
  end
94
+ format ||= @format
95
+ format = format.to_s.downcase
178
96
 
179
- output = (yaml_file?(path) ? YAML.dump(encrypted) : "#{JSON.pretty_generate(encrypted)}#{$/}")
97
+ output = (format == "yaml" ? YAML.dump(encrypted) : JSON.pretty_generate(encrypted))
98
+ output << $/ unless output.end_with?($/) # ensure file ends with system dependent new line
180
99
  File.open(path, "w") do |file|
181
100
  file.write(output)
182
101
  end
@@ -184,7 +103,7 @@ class SecretKeys < DelegateClass(Hash)
184
103
  end
185
104
 
186
105
  # 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.
106
+ # Values that have not changed will not be re-salted so the encrypted values will remain the same.
188
107
  #
189
108
  # @return [Hash] An encrypted hash that can be saved/parsed by a new instance of {SecretKeys}
190
109
  def encrypted_hash
@@ -199,10 +118,14 @@ class SecretKeys < DelegateClass(Hash)
199
118
  hash[key] = value
200
119
  end
201
120
  end
202
- encrypted = {
203
- SALT => @salt,
204
- ENCRYPTION_KEY => key_dummy_value
205
- }.merge(encrypt_values(encrypted))
121
+
122
+ unless encryption_key_matches?(@original_encrypted[ENCRYPTION_KEY])
123
+ @original_encrypted = {}
124
+ end
125
+ encrypted.merge!(encrypt_values(encrypted, @original_encrypted))
126
+ encrypted[SALT] = @salt
127
+ encrypted[ENCRYPTION_KEY] = (@original_encrypted[ENCRYPTION_KEY] || encrypted_known_value)
128
+ encrypted[VERSION_KEY] = CRYPTO_VERSION
206
129
 
207
130
  hash[ENCRYPTED] = encrypted
208
131
  hash
@@ -213,22 +136,27 @@ class SecretKeys < DelegateClass(Hash)
213
136
  # @param [String] new_encryption_key encryption key to use for future {#save} calls
214
137
  # @return [void]
215
138
  def encryption_key=(new_encryption_key)
139
+ @original_encrypted = {}
216
140
  update_secret(key: new_encryption_key)
217
141
  end
218
142
 
143
+ # Return the data format (:json or :yaml) for the original data. Defaults to :json.
144
+ #
145
+ # @return [String]
146
+ def input_format
147
+ @format
148
+ end
149
+
219
150
  private
220
151
 
221
152
  ENCRYPTED = ".encrypted"
222
153
  ENCRYPTION_KEY = ".key"
154
+ VERSION_KEY = ".version"
223
155
  SALT = ".salt"
224
156
 
225
- # Used as a known dummy value for verifying we have the correct key
157
+ # Used as a known value for verifying we have the correct key
226
158
  # DO NOT CHANGE!!!
227
- KNOWN_DUMMY_VALUE = "SECRET_KEY"
228
-
229
- KDF_ITERATIONS = 20_000
230
- HASH_FUNC = "sha256"
231
- KEY_LENGTH = 32
159
+ KNOWN_VALUE = "SECRET_KEY"
232
160
 
233
161
  # Load the JSON data in a file path or stream into a hash, decrypting all the encrypted values.
234
162
  #
@@ -236,8 +164,9 @@ class SecretKeys < DelegateClass(Hash)
236
164
  def load_secrets!(path_or_stream)
237
165
  @secret_keys = Set.new
238
166
  @values = {}
167
+ @original_encrypted = {}
239
168
 
240
- hash = nil
169
+ hash = {}
241
170
  if path_or_stream.is_a?(Hash)
242
171
  # HACK: Perform a marshal dump/load operation to get a deep copy of the hash.
243
172
  # Otherwise, we can end up using destructive `#delete` operations and mess
@@ -247,12 +176,17 @@ class SecretKeys < DelegateClass(Hash)
247
176
  data = path_or_stream.read
248
177
  hash = parse_data(data)
249
178
  end
250
- return if hash.nil? || hash.empty?
251
179
 
252
180
  encrypted_values = hash.delete(ENCRYPTED)
253
181
  if encrypted_values
182
+ @original_encrypted = Marshal.load(Marshal.dump(encrypted_values))
183
+
184
+ version = encrypted_values.delete(VERSION_KEY) || CRYPTO_VERSION
185
+ raise VersionError, "Unsupported file version #{version}. Max supported is #{CRYPTO_VERSION}." if version > CRYPTO_VERSION
186
+
254
187
  file_key = encrypted_values.delete(ENCRYPTION_KEY)
255
- update_secret(salt: encrypted_values.delete(SALT))
188
+ salt = (encrypted_values.delete(SALT) || Encryptor.random_salt)
189
+ update_secret(salt: salt)
256
190
 
257
191
  # Check that we are using the right key
258
192
  if file_key && !encryption_key_matches?(file_key)
@@ -260,6 +194,9 @@ class SecretKeys < DelegateClass(Hash)
260
194
  end
261
195
  @secret_keys = encrypted_values.keys
262
196
  hash.merge!(decrypt_values(encrypted_values))
197
+ elsif @salt.nil?
198
+ # if no salt exists, create one.
199
+ update_secret(salt: Encryptor.random_salt)
263
200
  end
264
201
 
265
202
  @values = hash
@@ -269,23 +206,37 @@ class SecretKeys < DelegateClass(Hash)
269
206
  # @param [String] data file data to parse
270
207
  # @return [Hash] data parsed to a hash
271
208
  def parse_data(data)
209
+ @format = :json
210
+ return {} if data.nil? || data.empty?
272
211
  JSON.parse(data)
273
212
  rescue JSON::JSONError
213
+ @format = :yaml
274
214
  YAML.safe_load(data)
275
215
  end
276
216
 
277
217
  # Recursively encrypt all values.
278
- def encrypt_values(values)
218
+ def encrypt_values(values, original)
279
219
  if values.is_a?(Hash)
280
220
  encrypted_hash = {}
281
- values.keys.each do |key|
282
- encrypted_hash[key.to_s] = encrypt_values(values[key])
221
+ values.each_key do |key|
222
+ key = key.to_s
223
+ original_value = original[key] if original.is_a?(Hash)
224
+ encrypted_hash[key] = encrypt_values(values[key], original_value)
283
225
  end
284
226
  encrypted_hash
285
227
  elsif values.is_a?(Enumerable)
286
- values.collect { |value| encrypt_values(value) }
228
+ if original.is_a?(Enumerable)
229
+ values.zip(original).collect { |value, original_value| encrypt_values(value, original_value) }
230
+ else
231
+ values.collect { |value| encrypt_values(value, nil) }
232
+ end
287
233
  else
288
- encrypt_value(values)
234
+ decrypted_original = decrypt_value(original)
235
+ if decrypted_original == values && decrypted_original != original
236
+ original
237
+ else
238
+ encrypt_value(values)
239
+ end
289
240
  end
290
241
  end
291
242
 
@@ -304,45 +255,14 @@ class SecretKeys < DelegateClass(Hash)
304
255
  end
305
256
  end
306
257
 
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
258
  # Helper method to encrypt a value.
339
259
  def encrypt_value(value)
340
- self.class.encrypt(value, @secret_key)
260
+ @encryptor.encrypt(value)
341
261
  end
342
262
 
343
263
  # Helper method to decrypt a value.
344
264
  def decrypt_value(encrypted_value)
345
- self.class.decrypt(encrypted_value, @secret_key)
265
+ @encryptor.decrypt(encrypted_value)
346
266
  end
347
267
 
348
268
  # Helper method to test if two values are both encrypted, but result in the same decrypted value.
@@ -362,23 +282,14 @@ class SecretKeys < DelegateClass(Hash)
362
282
  end
363
283
  end
364
284
 
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)
285
+ # This is an encrypted known value that can be used determine if the secret has changed.
286
+ def encrypted_known_value
287
+ encrypt_value(KNOWN_VALUE)
377
288
  end
378
289
 
379
290
  # Helper to check if our encryption key is correct
380
291
  def encryption_key_matches?(encrypted_key)
381
- decrypt_value(encrypted_key) == KNOWN_DUMMY_VALUE
292
+ decrypt_value(encrypted_key) == KNOWN_VALUE
382
293
  rescue OpenSSL::Cipher::CipherError
383
294
  # If the key fails to decrypt, then it cannot be correct
384
295
  false
@@ -389,10 +300,15 @@ class SecretKeys < DelegateClass(Hash)
389
300
  ext == "yaml" || ext == "yml"
390
301
  end
391
302
 
303
+ def json_file?(path)
304
+ ext = path.split(".").last.to_s.downcase
305
+ ext == "json"
306
+ end
307
+
392
308
  # Update the secret key by updating the salt
393
309
  #
394
- # @param key: new encryption key
395
- # @param salt: salt to use for secret
310
+ # @param key new encryption key
311
+ # @param salt salt to use for secret
396
312
  # @return [void]
397
313
  def update_secret(key: nil, salt: nil)
398
314
  @encryption_key = key unless key.nil? || key.empty?
@@ -400,28 +316,36 @@ class SecretKeys < DelegateClass(Hash)
400
316
 
401
317
  # Only update the secret if encryption key and salt are present
402
318
  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)
319
+ @encryptor = Encryptor.from_password(@encryption_key, @salt)
406
320
  end
321
+
407
322
  # Don't accidentally return the secret, dammit
408
323
  nil
409
324
  end
410
-
325
+
411
326
  # Logic to read an encryption key from environment variables if it is not explicitly supplied.
412
327
  # If it isn't specified, the value will be read from the SECRET_KEYS_ENCRYPTION_KEY environment
413
328
  # variable. Otherwise, it will be tried to read from the file specified by the
414
329
  # SECRET_KEYS_ENCRYPTION_KEY_FILE environment variable.
330
+ # @return [String, nil] the encryption key
415
331
  def read_encryption_key(encryption_key)
416
332
  return encryption_key if encryption_key && !encryption_key.empty?
417
- encryption_key = ENV['SECRET_KEYS_ENCRYPTION_KEY']
333
+
334
+ encryption_key = ENV["SECRET_KEYS_ENCRYPTION_KEY"]
418
335
  return encryption_key if encryption_key && !encryption_key.empty?
419
- encryption_key_file = ENV['SECRET_KEYS_ENCRYPTION_KEY_FILE']
336
+
337
+ encryption_key = nil
338
+ encryption_key_file = ENV["SECRET_KEYS_ENCRYPTION_KEY_FILE"]
420
339
  if encryption_key_file && !encryption_key_file.empty? && File.exist?(encryption_key_file)
421
- File.read(encryption_key_file).chomp
422
- else
423
- nil
340
+ encryption_key = File.read(encryption_key_file).chomp
424
341
  end
425
- end
426
342
 
343
+ # final check if encryption key was passed in
344
+ raise EncryptionKeyError.new("Encryption key not specified") if encryption_key.nil? || encryption_key.empty?
345
+
346
+ encryption_key
347
+ end
427
348
  end
349
+
350
+ require_relative "secret_keys/version"
351
+ require_relative "secret_keys/encryptor"