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
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d291d004402b3197d72a0210d4586770c7f63e9f1baf70dfb14c5d6d848b52a5
|
4
|
+
data.tar.gz: bbd6e6d3f8e625a31a0502c5099004e9ebbe278d21c39f674ed8a4d0d4c822b5
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1d1dae102dd23919a34187b5d6c1c89c8722635aa3937c56777b9dca19cf6a8a765b291bb3c33c792d4c258dc44eb9cf2aa858559171cacc716e73da8d29b24e
|
7
|
+
data.tar.gz: 527a4ab4a67eb63424dae4a284fe9a398efe2d98cf405184eaacfcfbd4f51140d0ffcf389ee35fa3d31202e14aab85ea88df886b3e782f933414fd6ae3de5cd0
|
data/MIT_LICENSE.txt
CHANGED
@@ -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
|
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
|
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
|
-
|
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
|
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
|
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
|
-
```
|
57
|
+
```javascript
|
49
58
|
{
|
50
59
|
".encrypted": {
|
51
60
|
"enc_key1": {
|
52
|
-
"num": 1,
|
53
|
-
"
|
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
|
-
|
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 --
|
103
|
+
secret_keys encrypt --secret=mysecret --in-place /path/to/file.json
|
70
104
|
```
|
71
105
|
|
72
|
-
If you don't specify the `--
|
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
|
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
|
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
|
-
|
80
|
-
|
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
|
135
|
+
$ secret_keys edit -s mysecret --set-encrypted password=value /path/to/file.json
|
136
|
+
{ ... }
|
84
137
|
|
85
|
-
#
|
86
|
-
|
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
|
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
|
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
|
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
|
-
|
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
|
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, `
|
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
|
1
|
+
1.0.0
|
data/bin/secret_keys
CHANGED
@@ -1,9 +1,6 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
|
3
|
-
|
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
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
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
|
-
|
55
|
-
|
56
|
-
|
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
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
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
|
data/lib/secret_keys.rb
CHANGED
@@ -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
|
-
|
17
|
-
|
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
|
-
|
96
|
-
|
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
|
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
|
-
|
120
|
-
update_secret(salt: SecureRandom.hex(8)) if @salt.nil?
|
37
|
+
|
121
38
|
super(@values)
|
122
39
|
end
|
123
40
|
|
124
|
-
# Convert
|
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
|
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
|
163
|
-
# @param [
|
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,
|
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
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
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 = (
|
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
|
-
#
|
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
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
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
|
157
|
+
# Used as a known value for verifying we have the correct key
|
226
158
|
# DO NOT CHANGE!!!
|
227
|
-
|
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 =
|
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
|
-
|
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.
|
282
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
#
|
366
|
-
def
|
367
|
-
|
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) ==
|
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
|
395
|
-
# @param salt
|
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
|
-
|
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
|
-
|
333
|
+
|
334
|
+
encryption_key = ENV["SECRET_KEYS_ENCRYPTION_KEY"]
|
418
335
|
return encryption_key if encryption_key && !encryption_key.empty?
|
419
|
-
|
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"
|