secret_keys 1.0.0.pre
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.editorconfig +8 -0
- data/.github/workflows/style.yml +11 -0
- data/.github/workflows/test.yml +42 -0
- data/.standard.yml +7 -0
- data/.yardopts +3 -0
- data/CHANGE_LOG.md +3 -0
- data/MIT_LICENSE.txt +21 -0
- data/README.md +126 -0
- data/VERSION +1 -0
- data/bin/secret_keys +128 -0
- data/lib/secret_keys.rb +427 -0
- data/secret_keys.gemspec +32 -0
- metadata +72 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: db4d05a36d5a69f053949e6be11cdedde1203d87d57e41b4609ffbea7118e3b6
|
4
|
+
data.tar.gz: f753189aaed3cae64ef13029a47ee68616a7ca95d309f2607c5427450f7ba63c
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 73b58cba572869c1e9a399620ee56b6acdc5b288a1f95337e611bd84ae7231417bbeeaaf3217bc28daec006cec304c79ed62ad6c86ee5acd25513689bfcd8f22
|
7
|
+
data.tar.gz: 9f0c00479cbbfad0d14695f6b446c7427e74865a47f9f05d26e16ad45179405bf82452fc54b283ff73433d93f268998b148eac050fb0583ea0ab58c98f2cf26e
|
data/.editorconfig
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
# Test runner copied from R167/backblaze
|
2
|
+
name: Run tests
|
3
|
+
on: [push, pull_request]
|
4
|
+
jobs:
|
5
|
+
rspec:
|
6
|
+
name: rspec ruby-${{ matrix.ruby }} (${{ matrix.os }})
|
7
|
+
runs-on: ${{ matrix.os }}
|
8
|
+
strategy:
|
9
|
+
fail-fast: false
|
10
|
+
matrix:
|
11
|
+
os: [ ubuntu-latest ]
|
12
|
+
ruby: [ 2.4, 2.5, 2.6, 2.7 ]
|
13
|
+
include: # define single matrix case that performs upload
|
14
|
+
- os: ubuntu-latest
|
15
|
+
ruby: 2.7
|
16
|
+
upload: "true"
|
17
|
+
steps:
|
18
|
+
- name: checkout
|
19
|
+
uses: actions/checkout@v2
|
20
|
+
- name: set up Ruby
|
21
|
+
uses: ruby/setup-ruby@v1
|
22
|
+
with:
|
23
|
+
ruby-version: ${{ matrix.ruby }}
|
24
|
+
# Caching makes life better
|
25
|
+
- name: build lockfile
|
26
|
+
run: |
|
27
|
+
bundle config path vendor/bundle
|
28
|
+
bundle lock
|
29
|
+
- uses: actions/cache@v1
|
30
|
+
with:
|
31
|
+
path: vendor/bundle
|
32
|
+
key: bundle-use-ruby-${{ matrix.os }}-${{ matrix.ruby }}-${{ hashFiles('**/Gemfile.lock') }}
|
33
|
+
restore-keys: |
|
34
|
+
bundle-use-ruby-${{ matrix.os }}-${{ matrix.ruby }}-
|
35
|
+
bundle-use-ruby-${{ matrix.os }}-
|
36
|
+
- name: install dependencies
|
37
|
+
run: |
|
38
|
+
bundle install --jobs 3 --retry 3
|
39
|
+
bundle clean
|
40
|
+
# Okay, back to what we really care about...
|
41
|
+
- name: spec
|
42
|
+
run: bundle exec rake spec
|
data/.standard.yml
ADDED
data/.yardopts
ADDED
data/CHANGE_LOG.md
ADDED
data/MIT_LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2020 Brian Durand
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,126 @@
|
|
1
|
+
# SecretKeys
|
2
|
+
|
3
|
+
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
|
+
|
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.
|
6
|
+
|
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.
|
8
|
+
|
9
|
+
## Usage
|
10
|
+
|
11
|
+
You can load the JSON/YAML from a file
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
secrets = SecretKeys.new("/path/to/file.json", "mysecretkey")
|
15
|
+
```
|
16
|
+
|
17
|
+
or a stream
|
18
|
+
|
19
|
+
```ruby
|
20
|
+
secrets = SecretKeys.new(File.open("/path/to/file.json"), "mysecretkey")
|
21
|
+
```
|
22
|
+
|
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.
|
24
|
+
|
25
|
+
The `SecretKeys` object delegates to `Hash` and can be treated as a hash for most purposes.
|
26
|
+
|
27
|
+
```ruby
|
28
|
+
password = secrets["password"]
|
29
|
+
```
|
30
|
+
|
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.
|
32
|
+
|
33
|
+
```ruby
|
34
|
+
# api_key is plaintext by default
|
35
|
+
secrets["api_key"] = "1234567890"
|
36
|
+
|
37
|
+
# mark api_key as a secret to encrypt
|
38
|
+
secrets.encrypt!("api_key")
|
39
|
+
|
40
|
+
# now, when we save, the value for api_key is encrypted
|
41
|
+
secrets.save("/path/to/file.json")
|
42
|
+
```
|
43
|
+
|
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.
|
45
|
+
|
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.
|
47
|
+
|
48
|
+
```json
|
49
|
+
{
|
50
|
+
".encrypted": {
|
51
|
+
"enc_key1": {
|
52
|
+
"num": 1,
|
53
|
+
"rec": ["<encrypted-val>", true],
|
54
|
+
"thing": "<encrypted-val>"
|
55
|
+
},
|
56
|
+
"enc_key2": "<encrypted-val>"
|
57
|
+
},
|
58
|
+
"unenc_key": "plaintext"
|
59
|
+
}
|
60
|
+
```
|
61
|
+
|
62
|
+
## Command Line Tool
|
63
|
+
|
64
|
+
You can use the `secret_keys` command line tool to manage your JSON files.
|
65
|
+
|
66
|
+
You can initialize a new file with the encrypt command.
|
67
|
+
|
68
|
+
```bash
|
69
|
+
secret_keys encrypt --key mysecret /path/to/file.json
|
70
|
+
```
|
71
|
+
|
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.
|
73
|
+
|
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.
|
75
|
+
|
76
|
+
You can add or modify keys through the command line as well.
|
77
|
+
|
78
|
+
```bash
|
79
|
+
# add an encrypted key
|
80
|
+
secret_keys encrypt --key mysecret --set password /path/to/file.json
|
81
|
+
|
82
|
+
# add an encrypted key with a value
|
83
|
+
secret_keys encrypt --key mysecret --set password=value /path/to/file.json
|
84
|
+
|
85
|
+
# encrypt all keys in the file
|
86
|
+
secret_keys encrypt --key mysecret --all /path/to/file.json
|
87
|
+
```
|
88
|
+
|
89
|
+
You can also decrypt or delete keys.
|
90
|
+
|
91
|
+
```bash
|
92
|
+
secret_keys encrypt --key mysecret --decrypt username --delete password /path/to/file.json
|
93
|
+
```
|
94
|
+
|
95
|
+
You can change the encryption key used in the file.
|
96
|
+
|
97
|
+
```bash
|
98
|
+
secret_keys encrypt --key mysecret --new-key newsecret /path/to/file.json
|
99
|
+
```
|
100
|
+
|
101
|
+
Finally, you can print the unencrypted file to STDOUT.
|
102
|
+
|
103
|
+
```bash
|
104
|
+
secret_keys decrypt --key mysecret /path/to/file.json
|
105
|
+
```
|
106
|
+
|
107
|
+
## File Format
|
108
|
+
|
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.
|
110
|
+
|
111
|
+
In this example, `key_1` is stored in plain text while `key_2` has been encrypted.
|
112
|
+
|
113
|
+
```json
|
114
|
+
{
|
115
|
+
".encrypted": {
|
116
|
+
".salt": "aecdfdb296983ec0",
|
117
|
+
".key": "$AES$:LNkaWu/g7gM7zu4qC/4FAGOANOLWcY86uqxQfFiHRVSvXSA23pY",
|
118
|
+
"foo": "$AES$:XcbGIW9ABbfcMv79+YK0MC8P7WWtEAfE2Y8S/MMN5Q",
|
119
|
+
"array": [
|
120
|
+
"$AES$:1WPr25fkbVbQWvTCiEHJOPT50970Z+D8qkYTnTk",
|
121
|
+
"$AES$:FgSCK3pG8RBtYFqzO/WmNwus2SABI5zGGmfkPEw"
|
122
|
+
],
|
123
|
+
},
|
124
|
+
"not_encrypted": "plain text value"
|
125
|
+
}
|
126
|
+
```
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
1.0.0.pre
|
data/bin/secret_keys
ADDED
@@ -0,0 +1,128 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
# TODO this command tool will be rewritten with better, more consistent options.
|
4
|
+
|
5
|
+
require 'optparse'
|
6
|
+
require_relative "../lib/secret_keys"
|
7
|
+
|
8
|
+
# Load enironment variables from .env file in current working directory if available.
|
9
|
+
if File.exist?(File.expand_path(".env"))
|
10
|
+
begin
|
11
|
+
require 'dotenv/load'
|
12
|
+
rescue LoadError
|
13
|
+
# Ignore; dotenv gem not available.
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
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
|
53
|
+
|
54
|
+
opts.on("--help", "Prints this help") do
|
55
|
+
puts opts.help
|
56
|
+
exit
|
57
|
+
end
|
58
|
+
end
|
59
|
+
parser.parse!(options)
|
60
|
+
|
61
|
+
unless ["encrypt", "decrypt"].include?(action)
|
62
|
+
STDERR.puts parser.banner
|
63
|
+
exit 1
|
64
|
+
end
|
65
|
+
|
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
|
127
|
+
exit 1
|
128
|
+
end
|
data/lib/secret_keys.rb
ADDED
@@ -0,0 +1,427 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "openssl"
|
4
|
+
require "json"
|
5
|
+
require "yaml"
|
6
|
+
require "securerandom"
|
7
|
+
require "delegate"
|
8
|
+
require "set"
|
9
|
+
require "pathname"
|
10
|
+
require "base64"
|
11
|
+
|
12
|
+
# Load a JSON file with encrypted values. This value can be used as a hash.
|
13
|
+
class SecretKeys < DelegateClass(Hash)
|
14
|
+
class EncryptionKeyError < ArgumentError; end
|
15
|
+
|
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
|
94
|
+
|
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
|
106
|
+
# were put into the ".encrypted" key manually and are not yet encrypted, they will be used
|
107
|
+
# as is without any decryption.
|
108
|
+
#
|
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)
|
110
|
+
# @param [String] encryption_key secret to use for encryption/decryption
|
111
|
+
#
|
112
|
+
# @note If no encryption key is passed, this will defautl to env var SECRET_KEYS_ENCRYPTION_KEY
|
113
|
+
# or (if that is empty) the value read from the file path in SECRET_KEYS_ENCRYPTION_KEY_FILE.
|
114
|
+
def initialize(path_or_stream, encryption_key = nil)
|
115
|
+
encryption_key = read_encryption_key(encryption_key)
|
116
|
+
update_secret(key: encryption_key)
|
117
|
+
path_or_stream = Pathname.new(path_or_stream) if path_or_stream.is_a?(String)
|
118
|
+
load_secrets!(path_or_stream)
|
119
|
+
# if no salt exists, create one.
|
120
|
+
update_secret(salt: SecureRandom.hex(8)) if @salt.nil?
|
121
|
+
super(@values)
|
122
|
+
end
|
123
|
+
|
124
|
+
# Convert the value into an actual Hash object.
|
125
|
+
#
|
126
|
+
# @return [Hash]
|
127
|
+
def to_h
|
128
|
+
@values
|
129
|
+
end
|
130
|
+
alias to_hash to_h
|
131
|
+
|
132
|
+
# Mark the key as being encrypted when the JSON is saved.
|
133
|
+
#
|
134
|
+
# @param [String] key key to mark as needing encryption
|
135
|
+
# @return [void]
|
136
|
+
def encrypt!(key)
|
137
|
+
@secret_keys << key
|
138
|
+
nil
|
139
|
+
end
|
140
|
+
|
141
|
+
# Mark the key as no longer being decrypted when the JSON is saved.
|
142
|
+
#
|
143
|
+
# @param [String] key key to mark as not needing encryption
|
144
|
+
# @return [void]
|
145
|
+
def decrypt!(key)
|
146
|
+
@secret_keys.delete(key)
|
147
|
+
nil
|
148
|
+
end
|
149
|
+
|
150
|
+
# Return true if the key is encrypted.
|
151
|
+
#
|
152
|
+
# @param [String] key key to check
|
153
|
+
# @return [Boolean]
|
154
|
+
def encrypted?(key)
|
155
|
+
@secret_keys.include?(key)
|
156
|
+
end
|
157
|
+
|
158
|
+
# Save the JSON to a file at the specified path. Encrypted values in the file
|
159
|
+
# will not be updated if the values have not changed (since each call uses a
|
160
|
+
# different initialization vector).
|
161
|
+
#
|
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
|
164
|
+
# @return [void]
|
165
|
+
def save(path, update: true)
|
166
|
+
# create a copy of the encrypted hash for working on
|
167
|
+
encrypted = encrypted_hash
|
168
|
+
|
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)
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
output = (yaml_file?(path) ? YAML.dump(encrypted) : "#{JSON.pretty_generate(encrypted)}#{$/}")
|
180
|
+
File.open(path, "w") do |file|
|
181
|
+
file.write(output)
|
182
|
+
end
|
183
|
+
nil
|
184
|
+
end
|
185
|
+
|
186
|
+
# 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.
|
188
|
+
#
|
189
|
+
# @return [Hash] An encrypted hash that can be saved/parsed by a new instance of {SecretKeys}
|
190
|
+
def encrypted_hash
|
191
|
+
raise EncryptionKeyError.new("Encryption key not specified") if @encryption_key.nil? || @encryption_key.empty?
|
192
|
+
|
193
|
+
hash = {}
|
194
|
+
encrypted = {}
|
195
|
+
@values.each do |key, value|
|
196
|
+
if @secret_keys.include?(key)
|
197
|
+
encrypted[key] = value
|
198
|
+
else
|
199
|
+
hash[key] = value
|
200
|
+
end
|
201
|
+
end
|
202
|
+
encrypted = {
|
203
|
+
SALT => @salt,
|
204
|
+
ENCRYPTION_KEY => key_dummy_value
|
205
|
+
}.merge(encrypt_values(encrypted))
|
206
|
+
|
207
|
+
hash[ENCRYPTED] = encrypted
|
208
|
+
hash
|
209
|
+
end
|
210
|
+
|
211
|
+
# Change the encryption key in the document. When saving later, this key will be used.
|
212
|
+
#
|
213
|
+
# @param [String] new_encryption_key encryption key to use for future {#save} calls
|
214
|
+
# @return [void]
|
215
|
+
def encryption_key=(new_encryption_key)
|
216
|
+
update_secret(key: new_encryption_key)
|
217
|
+
end
|
218
|
+
|
219
|
+
private
|
220
|
+
|
221
|
+
ENCRYPTED = ".encrypted"
|
222
|
+
ENCRYPTION_KEY = ".key"
|
223
|
+
SALT = ".salt"
|
224
|
+
|
225
|
+
# Used as a known dummy value for verifying we have the correct key
|
226
|
+
# DO NOT CHANGE!!!
|
227
|
+
KNOWN_DUMMY_VALUE = "SECRET_KEY"
|
228
|
+
|
229
|
+
KDF_ITERATIONS = 20_000
|
230
|
+
HASH_FUNC = "sha256"
|
231
|
+
KEY_LENGTH = 32
|
232
|
+
|
233
|
+
# Load the JSON data in a file path or stream into a hash, decrypting all the encrypted values.
|
234
|
+
#
|
235
|
+
# @return [void]
|
236
|
+
def load_secrets!(path_or_stream)
|
237
|
+
@secret_keys = Set.new
|
238
|
+
@values = {}
|
239
|
+
|
240
|
+
hash = nil
|
241
|
+
if path_or_stream.is_a?(Hash)
|
242
|
+
# HACK: Perform a marshal dump/load operation to get a deep copy of the hash.
|
243
|
+
# Otherwise, we can end up using destructive `#delete` operations and mess
|
244
|
+
# up deeply nested values for external code (esp. when loading key: .encrypted)
|
245
|
+
hash = Marshal.load(Marshal.dump(path_or_stream))
|
246
|
+
elsif path_or_stream
|
247
|
+
data = path_or_stream.read
|
248
|
+
hash = parse_data(data)
|
249
|
+
end
|
250
|
+
return if hash.nil? || hash.empty?
|
251
|
+
|
252
|
+
encrypted_values = hash.delete(ENCRYPTED)
|
253
|
+
if encrypted_values
|
254
|
+
file_key = encrypted_values.delete(ENCRYPTION_KEY)
|
255
|
+
update_secret(salt: encrypted_values.delete(SALT))
|
256
|
+
|
257
|
+
# Check that we are using the right key
|
258
|
+
if file_key && !encryption_key_matches?(file_key)
|
259
|
+
raise EncryptionKeyError.new("Incorrect encryption key")
|
260
|
+
end
|
261
|
+
@secret_keys = encrypted_values.keys
|
262
|
+
hash.merge!(decrypt_values(encrypted_values))
|
263
|
+
end
|
264
|
+
|
265
|
+
@values = hash
|
266
|
+
end
|
267
|
+
|
268
|
+
# Attempt to parse the file first using JSON and fallback to YAML
|
269
|
+
# @param [String] data file data to parse
|
270
|
+
# @return [Hash] data parsed to a hash
|
271
|
+
def parse_data(data)
|
272
|
+
JSON.parse(data)
|
273
|
+
rescue JSON::JSONError
|
274
|
+
YAML.safe_load(data)
|
275
|
+
end
|
276
|
+
|
277
|
+
# Recursively encrypt all values.
|
278
|
+
def encrypt_values(values)
|
279
|
+
if values.is_a?(Hash)
|
280
|
+
encrypted_hash = {}
|
281
|
+
values.keys.each do |key|
|
282
|
+
encrypted_hash[key.to_s] = encrypt_values(values[key])
|
283
|
+
end
|
284
|
+
encrypted_hash
|
285
|
+
elsif values.is_a?(Enumerable)
|
286
|
+
values.collect { |value| encrypt_values(value) }
|
287
|
+
else
|
288
|
+
encrypt_value(values)
|
289
|
+
end
|
290
|
+
end
|
291
|
+
|
292
|
+
# Recursively decrypt all values.
|
293
|
+
def decrypt_values(values)
|
294
|
+
if values.is_a?(Hash)
|
295
|
+
decrypted_hash = {}
|
296
|
+
values.each do |key, value|
|
297
|
+
decrypted_hash[key.to_s] = decrypt_values(value)
|
298
|
+
end
|
299
|
+
decrypted_hash
|
300
|
+
elsif values.is_a?(Enumerable)
|
301
|
+
values.collect { |value| decrypt_values(value) }
|
302
|
+
else
|
303
|
+
decrypt_value(values)
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
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
|
+
# Helper method to encrypt a value.
|
339
|
+
def encrypt_value(value)
|
340
|
+
self.class.encrypt(value, @secret_key)
|
341
|
+
end
|
342
|
+
|
343
|
+
# Helper method to decrypt a value.
|
344
|
+
def decrypt_value(encrypted_value)
|
345
|
+
self.class.decrypt(encrypted_value, @secret_key)
|
346
|
+
end
|
347
|
+
|
348
|
+
# Helper method to test if two values are both encrypted, but result in the same decrypted value.
|
349
|
+
def equal_encrypted_values?(value_1, value_2)
|
350
|
+
return true if value_1 == value_2
|
351
|
+
|
352
|
+
decrypt_val_1 = decrypt_value(value_1)
|
353
|
+
decrypt_val_2 = decrypt_value(value_2)
|
354
|
+
if decrypt_val_1 == decrypt_val_2
|
355
|
+
if value_1 == decrypt_val_1 || value_2 == decrypt_val_2
|
356
|
+
false
|
357
|
+
else
|
358
|
+
true
|
359
|
+
end
|
360
|
+
else
|
361
|
+
false
|
362
|
+
end
|
363
|
+
end
|
364
|
+
|
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)
|
377
|
+
end
|
378
|
+
|
379
|
+
# Helper to check if our encryption key is correct
|
380
|
+
def encryption_key_matches?(encrypted_key)
|
381
|
+
decrypt_value(encrypted_key) == KNOWN_DUMMY_VALUE
|
382
|
+
rescue OpenSSL::Cipher::CipherError
|
383
|
+
# If the key fails to decrypt, then it cannot be correct
|
384
|
+
false
|
385
|
+
end
|
386
|
+
|
387
|
+
def yaml_file?(path)
|
388
|
+
ext = path.split(".").last.to_s.downcase
|
389
|
+
ext == "yaml" || ext == "yml"
|
390
|
+
end
|
391
|
+
|
392
|
+
# Update the secret key by updating the salt
|
393
|
+
#
|
394
|
+
# @param key: new encryption key
|
395
|
+
# @param salt: salt to use for secret
|
396
|
+
# @return [void]
|
397
|
+
def update_secret(key: nil, salt: nil)
|
398
|
+
@encryption_key = key unless key.nil? || key.empty?
|
399
|
+
@salt = salt unless salt.nil? || salt.empty?
|
400
|
+
|
401
|
+
# Only update the secret if encryption key and salt are present
|
402
|
+
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)
|
406
|
+
end
|
407
|
+
# Don't accidentally return the secret, dammit
|
408
|
+
nil
|
409
|
+
end
|
410
|
+
|
411
|
+
# Logic to read an encryption key from environment variables if it is not explicitly supplied.
|
412
|
+
# If it isn't specified, the value will be read from the SECRET_KEYS_ENCRYPTION_KEY environment
|
413
|
+
# variable. Otherwise, it will be tried to read from the file specified by the
|
414
|
+
# SECRET_KEYS_ENCRYPTION_KEY_FILE environment variable.
|
415
|
+
def read_encryption_key(encryption_key)
|
416
|
+
return encryption_key if encryption_key && !encryption_key.empty?
|
417
|
+
encryption_key = ENV['SECRET_KEYS_ENCRYPTION_KEY']
|
418
|
+
return encryption_key if encryption_key && !encryption_key.empty?
|
419
|
+
encryption_key_file = ENV['SECRET_KEYS_ENCRYPTION_KEY_FILE']
|
420
|
+
if encryption_key_file && !encryption_key_file.empty? && File.exist?(encryption_key_file)
|
421
|
+
File.read(encryption_key_file).chomp
|
422
|
+
else
|
423
|
+
nil
|
424
|
+
end
|
425
|
+
end
|
426
|
+
|
427
|
+
end
|
data/secret_keys.gemspec
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
Gem::Specification.new do |spec|
|
2
|
+
spec.name = "secret_keys"
|
3
|
+
spec.version = File.read(File.expand_path("../VERSION", __FILE__)).strip
|
4
|
+
spec.authors = ["Brian Durand", "Winston Durand"]
|
5
|
+
spec.email = ["bbdurand@gmail.com", "me@winstondurand.com"]
|
6
|
+
|
7
|
+
spec.summary = "Simple mechanism for loading JSON file with encrypted values."
|
8
|
+
spec.homepage = "https://github.com/bdurand/secret_keys"
|
9
|
+
spec.license = "MIT"
|
10
|
+
|
11
|
+
# Specify which files should be added to the gem when it is released.
|
12
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
13
|
+
ignore_files = %w[
|
14
|
+
.gitignore
|
15
|
+
.travis.yml
|
16
|
+
Appraisals
|
17
|
+
Gemfile
|
18
|
+
Gemfile.lock
|
19
|
+
Rakefile
|
20
|
+
gemfiles/
|
21
|
+
spec/
|
22
|
+
]
|
23
|
+
spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do
|
24
|
+
`git ls-files -z`.split("\x0").reject { |f| ignore_files.any? { |path| f.start_with?(path) } }
|
25
|
+
end
|
26
|
+
|
27
|
+
spec.require_paths = ["lib"]
|
28
|
+
spec.bindir = "bin"
|
29
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
30
|
+
|
31
|
+
spec.add_development_dependency "bundler", "~>2.0"
|
32
|
+
end
|
metadata
ADDED
@@ -0,0 +1,72 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: secret_keys
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0.pre
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Brian Durand
|
8
|
+
- Winston Durand
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2020-05-25 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: bundler
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
requirements:
|
18
|
+
- - "~>"
|
19
|
+
- !ruby/object:Gem::Version
|
20
|
+
version: '2.0'
|
21
|
+
type: :development
|
22
|
+
prerelease: false
|
23
|
+
version_requirements: !ruby/object:Gem::Requirement
|
24
|
+
requirements:
|
25
|
+
- - "~>"
|
26
|
+
- !ruby/object:Gem::Version
|
27
|
+
version: '2.0'
|
28
|
+
description:
|
29
|
+
email:
|
30
|
+
- bbdurand@gmail.com
|
31
|
+
- me@winstondurand.com
|
32
|
+
executables:
|
33
|
+
- secret_keys
|
34
|
+
extensions: []
|
35
|
+
extra_rdoc_files: []
|
36
|
+
files:
|
37
|
+
- ".editorconfig"
|
38
|
+
- ".github/workflows/style.yml"
|
39
|
+
- ".github/workflows/test.yml"
|
40
|
+
- ".standard.yml"
|
41
|
+
- ".yardopts"
|
42
|
+
- CHANGE_LOG.md
|
43
|
+
- MIT_LICENSE.txt
|
44
|
+
- README.md
|
45
|
+
- VERSION
|
46
|
+
- bin/secret_keys
|
47
|
+
- lib/secret_keys.rb
|
48
|
+
- secret_keys.gemspec
|
49
|
+
homepage: https://github.com/bdurand/secret_keys
|
50
|
+
licenses:
|
51
|
+
- MIT
|
52
|
+
metadata: {}
|
53
|
+
post_install_message:
|
54
|
+
rdoc_options: []
|
55
|
+
require_paths:
|
56
|
+
- lib
|
57
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
63
|
+
requirements:
|
64
|
+
- - ">"
|
65
|
+
- !ruby/object:Gem::Version
|
66
|
+
version: 1.3.1
|
67
|
+
requirements: []
|
68
|
+
rubygems_version: 3.0.3
|
69
|
+
signing_key:
|
70
|
+
specification_version: 4
|
71
|
+
summary: Simple mechanism for loading JSON file with encrypted values.
|
72
|
+
test_files: []
|