attr_vault 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +3 -1
- data/README.md +95 -12
- data/attr_vault.gemspec +3 -2
- data/lib/attr_vault/keyring.rb +29 -13
- data/lib/attr_vault/version.rb +1 -1
- data/spec/attr_vault_spec.rb +435 -393
- data/spec/spec_helper.rb +30 -14
- metadata +13 -13
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 679c27532f9ed523f12ffad0a4ac79b7d70b95a3
|
4
|
+
data.tar.gz: 6fb6564391b43997048851d9ab6a706c98b9ee27
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4904567a0e64825c0396ffe7bfe4a6e5915b267ba083091d0e383cbc6226c07e9fa0952d24a231588da7ec1f9243cd2b0e01ec7b4ce73ae4bb490958f22ba2bd
|
7
|
+
data.tar.gz: 2979e267a1e776842472f9a8d86fb6125c549ef1fe08c9546b49869309f8afcdbcb62a8288e92ab443fbd10fc94921d547cbe6a5773c5d5eb737301766c71a8c
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -32,16 +32,98 @@ encrypted with which key, making it easy to age out keys when needed
|
|
32
32
|
|
33
33
|
### Keyring
|
34
34
|
|
35
|
-
Keys are managed through a keyring--a short JSON document
|
36
|
-
|
37
|
-
|
38
|
-
|
35
|
+
Keys are managed through a keyring--a short JSON document describing
|
36
|
+
your encryption keys. The keyring must be a JSON object mapping
|
37
|
+
numeric ids of the keys to the key values. A keyring must have at
|
38
|
+
least one key. For example:
|
39
|
+
|
40
|
+
```json
|
41
|
+
{
|
42
|
+
"1": "PV8+EHgJlHfsVVVstJHgEo+3OCSn4iJDzqJs55U650Q=",
|
43
|
+
"2": "0HyJ15am4haRsCyiFCxDdlKwl3G5yPNKTUbadpaIfPI="
|
44
|
+
}
|
45
|
+
```
|
46
|
+
|
47
|
+
The `id` is used to track which key encrypted which piece of data; a
|
48
|
+
key with a larger id is assumed to be newer. The `value` is the actual
|
49
|
+
bytes of the encryption key, used for encryption and verification: see
|
50
|
+
below.
|
51
|
+
|
52
|
+
#### Legacy keyrings
|
53
|
+
|
54
|
+
A legacy keyring format is also supported for backwards
|
55
|
+
compatibility. The keyring must be a JSON array of objects with the
|
56
|
+
fields `id`, `created_at`, and `value`, and also must have at least
|
57
|
+
one key:
|
58
|
+
|
59
|
+
```json
|
60
|
+
[
|
61
|
+
{
|
62
|
+
"id": "1380e471-038e-459a-801d-10e7988ee6a3",
|
63
|
+
"created_at": "2016-02-04 01:55:00+00",
|
64
|
+
"value": "PV8+EHgJlHfsVVVstJHgEo+3OCSn4iJDzqJs55U650Q="
|
65
|
+
}
|
66
|
+
]
|
67
|
+
```
|
68
|
+
|
69
|
+
The `id` must be a uuid. The `created_at` must be an ISO-8601
|
70
|
+
timestamp indicating the age of a key relative to the other keys. The
|
71
|
+
`value` is the same structure as for a normal keyring.
|
72
|
+
|
73
|
+
#### Legacy keyring migration
|
74
|
+
|
75
|
+
You can migrate from legacy keyrings to the new format via the
|
76
|
+
following process:
|
77
|
+
|
78
|
+
Add a new key_id column:
|
39
79
|
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
80
|
+
```ruby
|
81
|
+
Sequel.migration do
|
82
|
+
change do
|
83
|
+
alter_table(:diary_entries) do
|
84
|
+
add_column :new_key_id, :integer
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
```
|
89
|
+
|
90
|
+
Devise new numeric ids for all in-use keys (based on their
|
91
|
+
`created_at` dates), and link the ids with sql like the following:
|
92
|
+
|
93
|
+
```sql
|
94
|
+
WITH key_map(new_key_id, old_key_id) AS (
|
95
|
+
VALUES (1, 'first-uuid'),
|
96
|
+
(2, 'next-uuid'),
|
97
|
+
(3, '...')
|
98
|
+
)
|
99
|
+
UPDATE
|
100
|
+
diary_entries
|
101
|
+
SET
|
102
|
+
diary_entries.new_key_id = key_map.new_key_id
|
103
|
+
FROM
|
104
|
+
key_map
|
105
|
+
WHERE
|
106
|
+
diary_entries.key_id = key_map.old_key_id
|
107
|
+
```
|
108
|
+
|
109
|
+
Rename the new column to be used as the main key id and drop the old
|
110
|
+
id column:
|
111
|
+
|
112
|
+
```ruby
|
113
|
+
Sequel.migration do
|
114
|
+
change do
|
115
|
+
alter_table(:diary_entries) do
|
116
|
+
rename_column :key_id, :old_key_id
|
117
|
+
rename_column :new_key_id, :key_id
|
118
|
+
set_column_not_null :key_id
|
119
|
+
drop_column :old_key_id, :integer
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
```
|
44
124
|
|
125
|
+
Then change the keyring in your application to use the new numeric
|
126
|
+
ids.
|
45
127
|
|
46
128
|
### Encryption and verification
|
47
129
|
|
@@ -83,7 +165,7 @@ Postgres, where binary data is stored in `bytea` columns:
|
|
83
165
|
Sequel.migration do
|
84
166
|
change do
|
85
167
|
alter_table(:diary_entries) do
|
86
|
-
add_column :key_id, :
|
168
|
+
add_column :key_id, :integer
|
87
169
|
add_column :secret_stuff, :bytea
|
88
170
|
end
|
89
171
|
end
|
@@ -174,9 +256,10 @@ It's safe to use the same name as the name of the encrypted attribute.
|
|
174
256
|
|
175
257
|
Because AttrVault uses a keyring, with access to multiple keys at
|
176
258
|
once, key rotation is fairly straightforward: if you add a key to the
|
177
|
-
keyring with a
|
178
|
-
|
179
|
-
|
259
|
+
keyring with a higher id than any other key (or more recent
|
260
|
+
`created_at` for the legacy keyring format), that key will
|
261
|
+
automatically be used for encryption. Any keys that are no longer in
|
262
|
+
use can be removed from the keyring.
|
180
263
|
|
181
264
|
To check if an existing key with id 123 is still in use, run:
|
182
265
|
|
data/attr_vault.gemspec
CHANGED
@@ -15,7 +15,8 @@ Gem::Specification.new do |gem|
|
|
15
15
|
gem.version = AttrVault::VERSION
|
16
16
|
gem.license = "MIT"
|
17
17
|
|
18
|
+
gem.add_runtime_dependency "pg", '~> 0.18.3'
|
19
|
+
gem.add_runtime_dependency "sequel", '~> 4.13'
|
20
|
+
|
18
21
|
gem.add_development_dependency "rspec", '~> 3.0'
|
19
|
-
gem.add_development_dependency "pg", '~> 0.18.3'
|
20
|
-
gem.add_development_dependency "sequel", '~> 4.13'
|
21
22
|
end
|
data/lib/attr_vault/keyring.rb
CHANGED
@@ -2,15 +2,19 @@ module AttrVault
|
|
2
2
|
class Key
|
3
3
|
attr_reader :id, :value, :created_at
|
4
4
|
|
5
|
-
def initialize(id, value, created_at)
|
5
|
+
def initialize(id, value, created_at=nil)
|
6
6
|
if id.nil?
|
7
7
|
raise InvalidKey, "key id required"
|
8
8
|
end
|
9
9
|
if value.nil?
|
10
10
|
raise InvalidKey, "key value required"
|
11
11
|
end
|
12
|
-
|
13
|
-
|
12
|
+
begin
|
13
|
+
id = Integer(id)
|
14
|
+
rescue
|
15
|
+
if created_at.nil?
|
16
|
+
raise InvalidKey, "key created_at required"
|
17
|
+
end
|
14
18
|
end
|
15
19
|
|
16
20
|
@id = id
|
@@ -33,15 +37,20 @@ module AttrVault
|
|
33
37
|
def self.load(keyring_data)
|
34
38
|
keyring = Keyring.new
|
35
39
|
begin
|
36
|
-
candidate_keys = JSON.parse(keyring_data)
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
40
|
+
candidate_keys = JSON.parse(keyring_data, symbolize_names: true)
|
41
|
+
|
42
|
+
case candidate_keys
|
43
|
+
when Array
|
44
|
+
candidate_keys.each do |k|
|
45
|
+
created_at = Time.parse(k[:created_at]) if k.has_key?(:created_at)
|
46
|
+
keyring.add_key(Key.new(k[:id], k[:value], created_at || Time.now))
|
47
|
+
end
|
48
|
+
when Hash
|
49
|
+
candidate_keys.each do |key_id, key|
|
50
|
+
keyring.add_key(Key.new(key_id.to_s, key))
|
51
|
+
end
|
52
|
+
else
|
53
|
+
raise InvalidKeyring, "Invalid JSON structure"
|
45
54
|
end
|
46
55
|
rescue StandardError => e
|
47
56
|
raise InvalidKeyring, e.message
|
@@ -87,7 +96,14 @@ module AttrVault
|
|
87
96
|
end
|
88
97
|
|
89
98
|
def to_json
|
90
|
-
@keys.
|
99
|
+
if @keys.all? { |k| k.created_at.nil? }
|
100
|
+
@keys.each_with_object({}) do |k,obj|
|
101
|
+
obj[k.id] = k.value
|
102
|
+
end.to_json
|
103
|
+
else
|
104
|
+
# Assume we are dealing with a legacy keyring
|
105
|
+
@keys.to_json
|
106
|
+
end
|
91
107
|
end
|
92
108
|
end
|
93
109
|
end
|
data/lib/attr_vault/version.rb
CHANGED
data/spec/attr_vault_spec.rb
CHANGED
@@ -2,447 +2,489 @@ require 'spec_helper'
|
|
2
2
|
require 'json'
|
3
3
|
|
4
4
|
describe AttrVault do
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
Class.new(Sequel::Model(:items)) do
|
15
|
-
include AttrVault
|
16
|
-
vault_keyring k
|
17
|
-
vault_attr :secret
|
18
|
-
end
|
19
|
-
end
|
20
|
-
|
21
|
-
context "with a new object" do
|
22
|
-
it "does not affect other attributes" do
|
23
|
-
not_secret = 'jimi hendrix was rather talented'
|
24
|
-
s = item.create(not_secret: not_secret)
|
25
|
-
s.reload
|
26
|
-
expect(s.not_secret).to eq(not_secret)
|
27
|
-
expect(s.this.where(not_secret: not_secret).count).to eq 1
|
5
|
+
[ [ 'numeric', :items, [ 1, 2 ] ],
|
6
|
+
[ 'uuid', :items_legacy,
|
7
|
+
[ 'bf72f2ca-b478-4abf-a077-8eb7e071810f',
|
8
|
+
'5a8d7477-6604-4801-9f1d-d4f412890cc3' ] ] ].each do |desc, table, key_ids|
|
9
|
+
|
10
|
+
describe "with #{desc} key ids" do
|
11
|
+
let(:key_values) do
|
12
|
+
[ 'aFJDXs+798G7wgS/nap21LXIpm/Rrr39jIVo2m/cdj8=',
|
13
|
+
'hUL1orBBRckZOuSuptRXYMV9lx5Qp54zwFUVwpwTpdk=' ]
|
28
14
|
end
|
29
15
|
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
16
|
+
def make_keyring(key_ids)
|
17
|
+
paired = key_ids.zip(key_values)
|
18
|
+
if key_ids.all? { |id| id.is_a? Integer }
|
19
|
+
Hash[paired]
|
20
|
+
else
|
21
|
+
result = []
|
22
|
+
paired.each_with_index do |(id,val), i|
|
23
|
+
result << { id: id, created_at: Time.now + (i * 60), value: val }
|
24
|
+
end
|
25
|
+
result
|
37
26
|
end
|
38
27
|
end
|
39
28
|
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
s.reload
|
44
|
-
expect(s.secret).to eq('')
|
45
|
-
expect(s.secret_encrypted).to eq('')
|
46
|
-
end
|
47
|
-
|
48
|
-
it "stores nil values as nil" do
|
49
|
-
s = item.create(secret: nil)
|
50
|
-
s.reload
|
51
|
-
expect(s.secret).to be_nil
|
52
|
-
expect(s.secret_encrypted).to be_nil
|
53
|
-
end
|
29
|
+
let(:key1_id) { key_ids.first }
|
30
|
+
let(:key2_id) { key_ids.fetch(1) }
|
31
|
+
let(:key_id) { key1_id }
|
54
32
|
|
55
|
-
|
56
|
-
|
57
|
-
s.reload
|
58
|
-
s.update(secret: '')
|
59
|
-
s.reload
|
60
|
-
expect(s.secret).to eq ''
|
61
|
-
expect(s.secret_encrypted).not_to be_nil
|
33
|
+
let(:old_keyring) do
|
34
|
+
make_keyring(key_ids.take(1))
|
62
35
|
end
|
63
|
-
|
64
|
-
|
65
|
-
the_secret = "I'm not saying it was aliens..."
|
66
|
-
s = item.create(secret: the_secret)
|
67
|
-
s.reload
|
68
|
-
s.secret = the_secret
|
69
|
-
expect(s.save_changes).to be_nil
|
70
|
-
end
|
71
|
-
|
72
|
-
it "stores the key id" do
|
73
|
-
secret = 'it was professor plum with the wrench in the library'
|
74
|
-
s = item.create(secret: secret)
|
75
|
-
s.reload
|
76
|
-
expect(s.key_id).to eq(key_id)
|
77
|
-
end
|
78
|
-
end
|
79
|
-
|
80
|
-
context "with an existing object" do
|
81
|
-
it "does not affect other attributes" do
|
82
|
-
not_secret = 'soylent is not especially tasty'
|
83
|
-
s = item.create
|
84
|
-
s.update(not_secret: not_secret)
|
85
|
-
s.reload
|
86
|
-
expect(s.not_secret).to eq(not_secret)
|
87
|
-
expect(s.this.where(not_secret: not_secret).count).to eq 1
|
36
|
+
let(:new_keyring) do
|
37
|
+
make_keyring(key_ids)
|
88
38
|
end
|
39
|
+
let(:keyring) { old_keyring }
|
89
40
|
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
expect(s.secret).to eq(secret)
|
96
|
-
s.columns.each do |col|
|
97
|
-
expect(s.this.where(Sequel.cast(Sequel.cast(col, :text), :bytea) => secret).count).to eq 0
|
41
|
+
let(:key1) do
|
42
|
+
if keyring.is_a?(Hash)
|
43
|
+
keyring[key1_id]
|
44
|
+
else
|
45
|
+
keyring.find { |k| k[:id] == key1_id }
|
98
46
|
end
|
99
47
|
end
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
expect(s.secret_encrypted).to eq('')
|
107
|
-
end
|
108
|
-
|
109
|
-
it "leaves nil values as nil" do
|
110
|
-
s = item.create(secret: "dr. crowe was dead all along")
|
111
|
-
s.update(secret: nil)
|
112
|
-
s.reload
|
113
|
-
expect(s.secret).to be_nil
|
114
|
-
expect(s.secret_encrypted).to be_nil
|
115
|
-
end
|
116
|
-
|
117
|
-
it "stores the key id" do
|
118
|
-
secret = 'animal style'
|
119
|
-
s = item.create
|
120
|
-
s.update(secret: secret)
|
121
|
-
s.reload
|
122
|
-
expect(s.key_id).to eq(key_id)
|
123
|
-
end
|
124
|
-
|
125
|
-
it "reads a never-set encrypted field as nil" do
|
126
|
-
s = item.create
|
127
|
-
expect(s.secret).to be_nil
|
128
|
-
end
|
129
|
-
|
130
|
-
it "avoids updating existing values when those do not change" do
|
131
|
-
the_secret = "Satoshi Nakamoto"
|
132
|
-
s = item.create
|
133
|
-
s.update(secret: the_secret)
|
134
|
-
s.secret = the_secret
|
135
|
-
expect(s.save_changes).to be_nil
|
48
|
+
let(:key2) do
|
49
|
+
if keyring.is_a?(Hash)
|
50
|
+
keyring[key2_id]
|
51
|
+
else
|
52
|
+
keyring.find { |k| k[:id] == key2_id }
|
53
|
+
end
|
136
54
|
end
|
55
|
+
let(:key) { key1 }
|
56
|
+
|
57
|
+
context "with a single encrypted column" do
|
58
|
+
let(:item) do
|
59
|
+
k = keyring.to_json
|
60
|
+
t = table
|
61
|
+
Class.new(Sequel::Model(t)) do
|
62
|
+
include AttrVault
|
63
|
+
vault_keyring k
|
64
|
+
vault_attr :secret
|
65
|
+
end
|
66
|
+
end
|
137
67
|
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
68
|
+
context "with a new object" do
|
69
|
+
it "does not affect other attributes" do
|
70
|
+
not_secret = 'jimi hendrix was rather talented'
|
71
|
+
s = item.create(not_secret: not_secret)
|
72
|
+
s.reload
|
73
|
+
expect(s.not_secret).to eq(not_secret)
|
74
|
+
expect(s.this.where(not_secret: not_secret).count).to eq 1
|
75
|
+
end
|
76
|
+
|
77
|
+
it "encrypts non-empty values" do
|
78
|
+
secret = 'lady gaga? also rather talented'
|
79
|
+
s = item.create(secret: secret)
|
80
|
+
s.reload
|
81
|
+
expect(s.secret).to eq(secret)
|
82
|
+
s.columns.each do |col|
|
83
|
+
expect(s.this.where(Sequel.cast(Sequel.cast(col, :text), :bytea) => secret).count).to eq 0
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
it "stores empty values as empty" do
|
88
|
+
secret = ''
|
89
|
+
s = item.create(secret: secret)
|
90
|
+
s.reload
|
91
|
+
expect(s.secret).to eq('')
|
92
|
+
expect(s.secret_encrypted).to eq('')
|
93
|
+
end
|
94
|
+
|
95
|
+
it "stores nil values as nil" do
|
96
|
+
s = item.create(secret: nil)
|
97
|
+
s.reload
|
98
|
+
expect(s.secret).to be_nil
|
99
|
+
expect(s.secret_encrypted).to be_nil
|
100
|
+
end
|
101
|
+
|
102
|
+
it "sets fields to empty that were previously not empty" do
|
103
|
+
s = item.create(secret: 'joyce hatto')
|
104
|
+
s.reload
|
105
|
+
s.update(secret: '')
|
106
|
+
s.reload
|
107
|
+
expect(s.secret).to eq ''
|
108
|
+
expect(s.secret_encrypted).not_to be_nil
|
109
|
+
end
|
110
|
+
|
111
|
+
it "avoids updating existing values when those do not change" do
|
112
|
+
the_secret = "I'm not saying it was aliens..."
|
113
|
+
s = item.create(secret: the_secret)
|
114
|
+
s.reload
|
115
|
+
s.secret = the_secret
|
116
|
+
expect(s.save_changes).to be_nil
|
117
|
+
end
|
118
|
+
|
119
|
+
it "stores the key id" do
|
120
|
+
secret = 'it was professor plum with the wrench in the library'
|
121
|
+
s = item.create(secret: secret)
|
122
|
+
s.reload
|
123
|
+
expect(s.key_id).to eq(key_id)
|
124
|
+
end
|
125
|
+
end
|
146
126
|
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
127
|
+
context "with an existing object" do
|
128
|
+
it "does not affect other attributes" do
|
129
|
+
not_secret = 'soylent is not especially tasty'
|
130
|
+
s = item.create
|
131
|
+
s.update(not_secret: not_secret)
|
132
|
+
s.reload
|
133
|
+
expect(s.not_secret).to eq(not_secret)
|
134
|
+
expect(s.this.where(not_secret: not_secret).count).to eq 1
|
135
|
+
end
|
136
|
+
|
137
|
+
it "encrypts non-empty values" do
|
138
|
+
secret = 'soylent green is made of people'
|
139
|
+
s = item.create
|
140
|
+
s.update(secret: secret)
|
141
|
+
s.reload
|
142
|
+
expect(s.secret).to eq(secret)
|
143
|
+
s.columns.each do |col|
|
144
|
+
expect(s.this.where(Sequel.cast(Sequel.cast(col, :text), :bytea) => secret).count).to eq 0
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
it "stores empty values as empty" do
|
149
|
+
s = item.create(secret: "darth vader is luke's father")
|
150
|
+
s.update(secret: '')
|
151
|
+
s.reload
|
152
|
+
expect(s.secret).to eq('')
|
153
|
+
expect(s.secret_encrypted).to eq('')
|
154
|
+
end
|
155
|
+
|
156
|
+
it "leaves nil values as nil" do
|
157
|
+
s = item.create(secret: "dr. crowe was dead all along")
|
158
|
+
s.update(secret: nil)
|
159
|
+
s.reload
|
160
|
+
expect(s.secret).to be_nil
|
161
|
+
expect(s.secret_encrypted).to be_nil
|
162
|
+
end
|
163
|
+
|
164
|
+
it "stores the key id" do
|
165
|
+
secret = 'animal style'
|
166
|
+
s = item.create
|
167
|
+
s.update(secret: secret)
|
168
|
+
s.reload
|
169
|
+
expect(s.key_id).to eq(key_id)
|
170
|
+
end
|
171
|
+
|
172
|
+
it "reads a never-set encrypted field as nil" do
|
173
|
+
s = item.create
|
174
|
+
expect(s.secret).to be_nil
|
175
|
+
end
|
176
|
+
|
177
|
+
it "avoids updating existing values when those do not change" do
|
178
|
+
the_secret = "Satoshi Nakamoto"
|
179
|
+
s = item.create
|
180
|
+
s.update(secret: the_secret)
|
181
|
+
s.secret = the_secret
|
182
|
+
expect(s.save_changes).to be_nil
|
183
|
+
end
|
184
|
+
|
185
|
+
it "reads the correct value for a dirty field before the object is saved" do
|
186
|
+
s = item.create
|
187
|
+
secret = 'mcmurphy is lobotomized =('
|
188
|
+
s.secret = secret
|
189
|
+
expect(s.secret).to eq secret
|
190
|
+
end
|
191
|
+
end
|
160
192
|
end
|
161
|
-
end
|
162
193
|
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
194
|
+
context "with multiple encrypted columns" do
|
195
|
+
let(:key_data) do
|
196
|
+
[ { id: 1,
|
197
|
+
value: 'aFJDXs+798G7wgS/nap21LXIpm/Rrr39jIVo2m/cdj8=' } ].to_json
|
198
|
+
end
|
199
|
+
let(:item) do
|
200
|
+
k = key_data
|
201
|
+
Class.new(Sequel::Model(:items)) do
|
202
|
+
include AttrVault
|
203
|
+
vault_keyring k
|
204
|
+
vault_attr :secret
|
205
|
+
vault_attr :other
|
206
|
+
end
|
207
|
+
end
|
175
208
|
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
created_at: Time.new(2014, 2, 1, 0, 0, 0) }
|
188
|
-
end
|
189
|
-
let(:partial_keyring) { [key1].to_json }
|
190
|
-
let(:full_keyring) { [key1, key2].to_json }
|
191
|
-
let(:item1) do
|
192
|
-
k = partial_keyring
|
193
|
-
Class.new(Sequel::Model(:items)) do
|
194
|
-
include AttrVault
|
195
|
-
vault_keyring k
|
196
|
-
vault_attr :secret
|
197
|
-
vault_attr :other
|
198
|
-
end
|
199
|
-
end
|
200
|
-
let(:item2) do
|
201
|
-
k = full_keyring
|
202
|
-
Class.new(Sequel::Model(:items)) do
|
203
|
-
include AttrVault
|
204
|
-
vault_keyring k
|
205
|
-
vault_attr :secret
|
206
|
-
vault_attr :other
|
209
|
+
it "does not clobber other attributes" do
|
210
|
+
secret1 = "superman is really mild-mannered reporter clark kent"
|
211
|
+
secret2 = "batman is really millionaire playboy bruce wayne"
|
212
|
+
s = item.create(secret: secret1)
|
213
|
+
s.reload
|
214
|
+
expect(s.secret).to eq secret1
|
215
|
+
s.update(other: secret2)
|
216
|
+
s.reload
|
217
|
+
expect(s.secret).to eq secret1
|
218
|
+
expect(s.other).to eq secret2
|
219
|
+
end
|
207
220
|
end
|
208
|
-
end
|
209
221
|
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
222
|
+
context "with items encrypted with an older key" do
|
223
|
+
let(:key1_id) { 1 }
|
224
|
+
let(:key1) do
|
225
|
+
{ id: key1_id,
|
226
|
+
value: 'aFJDXs+798G7wgS/nap21LXIpm/Rrr39jIVo2m/cdj8=' }
|
227
|
+
end
|
228
|
+
let(:key2_id) { 2 }
|
229
|
+
let(:key2) do
|
230
|
+
{ id: key2_id,
|
231
|
+
value: 'hUL1orBBRckZOuSuptRXYMV9lx5Qp54zwFUVwpwTpdk=' }
|
232
|
+
end
|
233
|
+
let(:partial_keyring) { [key1].to_json }
|
234
|
+
let(:full_keyring) { [key1, key2].to_json }
|
235
|
+
let(:item1) do
|
236
|
+
k = partial_keyring
|
237
|
+
Class.new(Sequel::Model(:items)) do
|
238
|
+
include AttrVault
|
239
|
+
vault_keyring k
|
240
|
+
vault_attr :secret
|
241
|
+
vault_attr :other
|
242
|
+
end
|
243
|
+
end
|
244
|
+
let(:item2) do
|
245
|
+
k = full_keyring
|
246
|
+
Class.new(Sequel::Model(:items)) do
|
247
|
+
include AttrVault
|
248
|
+
vault_keyring k
|
249
|
+
vault_attr :secret
|
250
|
+
vault_attr :other
|
251
|
+
end
|
252
|
+
end
|
216
253
|
|
217
|
-
|
254
|
+
it "rewrites the items using the current key" do
|
255
|
+
secret1 = 'mrs. doubtfire is really a man'
|
256
|
+
secret2 = 'tootsie? also a man'
|
257
|
+
record = item1.create(secret: secret1)
|
258
|
+
expect(record.key_id).to eq key1_id
|
259
|
+
expect(record.secret).to eq secret1
|
218
260
|
|
219
|
-
|
220
|
-
new_key_record.update(secret: secret2)
|
221
|
-
new_key_record.reload
|
261
|
+
old_secret_encrypted = record.secret_encrypted
|
222
262
|
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
end
|
263
|
+
new_key_record = item2[record.id]
|
264
|
+
new_key_record.update(secret: secret2)
|
265
|
+
new_key_record.reload
|
227
266
|
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
expect(record.key_id).to eq key1_id
|
233
|
-
expect(record.secret).to eq secret1
|
267
|
+
expect(new_key_record.key_id).to eq key2_id
|
268
|
+
expect(new_key_record.secret).to eq secret2
|
269
|
+
expect(new_key_record.secret_encrypted).not_to eq old_secret_encrypted
|
270
|
+
end
|
234
271
|
|
235
|
-
|
272
|
+
it "rewrites the items using the current key even if they are not updated" do
|
273
|
+
secret1 = 'the planet of the apes is really earth'
|
274
|
+
secret2 = 'the answer is 42'
|
275
|
+
record = item1.create(secret: secret1)
|
276
|
+
expect(record.key_id).to eq key1_id
|
277
|
+
expect(record.secret).to eq secret1
|
236
278
|
|
237
|
-
|
238
|
-
new_key_record.update(other: secret2)
|
239
|
-
new_key_record.reload
|
279
|
+
old_secret_encrypted = record.secret_encrypted
|
240
280
|
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
expect(new_key_record.other).to eq secret2
|
245
|
-
end
|
246
|
-
end
|
281
|
+
new_key_record = item2[record.id]
|
282
|
+
new_key_record.update(other: secret2)
|
283
|
+
new_key_record.reload
|
247
284
|
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
created_at: Time.now } ].to_json
|
254
|
-
end
|
255
|
-
let(:item1) do
|
256
|
-
k = key_data
|
257
|
-
Class.new(Sequel::Model(:items)) do
|
258
|
-
include AttrVault
|
259
|
-
vault_keyring k
|
260
|
-
vault_attr :secret
|
261
|
-
vault_attr :other
|
262
|
-
end
|
263
|
-
end
|
264
|
-
let(:item2) do
|
265
|
-
k = key_data
|
266
|
-
Class.new(Sequel::Model(:items)) do
|
267
|
-
include AttrVault
|
268
|
-
vault_keyring k
|
269
|
-
vault_attr :secret, migrate_from_field: :not_secret
|
270
|
-
vault_attr :other, migrate_from_field: :other_not_secret
|
285
|
+
expect(new_key_record.key_id).to eq key2_id
|
286
|
+
expect(new_key_record.secret).to eq secret1
|
287
|
+
expect(new_key_record.secret_encrypted).not_to eq old_secret_encrypted
|
288
|
+
expect(new_key_record.other).to eq secret2
|
289
|
+
end
|
271
290
|
end
|
272
|
-
end
|
273
291
|
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
end
|
292
|
+
context "with plaintext source fields" do
|
293
|
+
let(:key_id) { 1 }
|
294
|
+
let(:key_data) do
|
295
|
+
[ { id: key_id,
|
296
|
+
value: 'aFJDXs+798G7wgS/nap21LXIpm/Rrr39jIVo2m/cdj8=' } ].to_json
|
297
|
+
end
|
298
|
+
let(:item1) do
|
299
|
+
k = key_data
|
300
|
+
Class.new(Sequel::Model(:items)) do
|
301
|
+
include AttrVault
|
302
|
+
vault_keyring k
|
303
|
+
vault_attr :secret
|
304
|
+
vault_attr :other
|
305
|
+
end
|
306
|
+
end
|
307
|
+
let(:item2) do
|
308
|
+
k = key_data
|
309
|
+
Class.new(Sequel::Model(:items)) do
|
310
|
+
include AttrVault
|
311
|
+
vault_keyring k
|
312
|
+
vault_attr :secret, migrate_from_field: :not_secret
|
313
|
+
vault_attr :other, migrate_from_field: :other_not_secret
|
314
|
+
end
|
315
|
+
end
|
299
316
|
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
expect(reloaded.not_secret).to be_nil
|
311
|
-
end
|
312
|
-
end
|
317
|
+
it "copies a plaintext field to an encrypted field when saving the object" do
|
318
|
+
becomes_secret = 'the location of the lost continent of atlantis'
|
319
|
+
s = item1.create(not_secret: becomes_secret)
|
320
|
+
reloaded = item2[s.id]
|
321
|
+
expect(reloaded.not_secret).to eq becomes_secret
|
322
|
+
reloaded.save
|
323
|
+
reloaded.reload
|
324
|
+
expect(reloaded.not_secret).to be_nil
|
325
|
+
expect(reloaded.secret).to eq becomes_secret
|
326
|
+
end
|
313
327
|
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
328
|
+
it "supports converting multiple fields" do
|
329
|
+
becomes_secret1 = 'the location of the fountain of youth'
|
330
|
+
becomes_secret2 = 'the location of the lost city of el dorado'
|
331
|
+
s = item1.create(not_secret: becomes_secret1, other_not_secret: becomes_secret2)
|
332
|
+
reloaded = item2[s.id]
|
333
|
+
expect(reloaded.not_secret).to eq becomes_secret1
|
334
|
+
expect(reloaded.other_not_secret).to eq becomes_secret2
|
335
|
+
reloaded.save
|
336
|
+
reloaded.reload
|
337
|
+
expect(reloaded.not_secret).to be_nil
|
338
|
+
expect(reloaded.secret).to eq becomes_secret1
|
339
|
+
expect(reloaded.other_not_secret).to be_nil
|
340
|
+
expect(reloaded.other).to eq becomes_secret2
|
341
|
+
end
|
320
342
|
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
343
|
+
it "nils out the plaintext field and persists the encrypted field on save" do
|
344
|
+
becomes_secret = 'the location of all those socks that disappear from the dryer'
|
345
|
+
new_secret = 'the location of pliny the younger drafts'
|
346
|
+
s = item1.create(not_secret: becomes_secret)
|
347
|
+
reloaded = item2[s.id]
|
348
|
+
expect(reloaded.secret).to eq(becomes_secret)
|
349
|
+
reloaded.secret = new_secret
|
350
|
+
expect(reloaded.secret).to eq(new_secret)
|
351
|
+
reloaded.save
|
352
|
+
expect(reloaded.secret).to eq(new_secret)
|
353
|
+
expect(reloaded.not_secret).to be_nil
|
354
|
+
end
|
328
355
|
end
|
329
356
|
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
end
|
357
|
+
context "with renamed database fields" do
|
358
|
+
let(:key_data) do
|
359
|
+
[ { id: 1,
|
360
|
+
value: 'aFJDXs+798G7wgS/nap21LXIpm/Rrr39jIVo2m/cdj8=' } ].to_json
|
361
|
+
end
|
336
362
|
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
363
|
+
it "supports renaming the encrypted field" do
|
364
|
+
k = key_data
|
365
|
+
item = Class.new(Sequel::Model(:items)) do
|
366
|
+
include AttrVault
|
367
|
+
vault_keyring k
|
368
|
+
vault_attr :classified_info,
|
369
|
+
encrypted_field: :secret_encrypted
|
370
|
+
end
|
371
|
+
|
372
|
+
secret = "we've secretly replaced the fine coffee they usually serve with Folgers Crystals"
|
373
|
+
s = item.create(classified_info: secret)
|
374
|
+
s.reload
|
375
|
+
expect(s.classified_info).to eq secret
|
376
|
+
expect(s.secret_encrypted).not_to eq secret
|
377
|
+
end
|
344
378
|
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
end
|
353
|
-
end
|
379
|
+
it "supports renaming the key id field" do
|
380
|
+
k = key_data
|
381
|
+
item = Class.new(Sequel::Model(:items)) do
|
382
|
+
include AttrVault
|
383
|
+
vault_keyring k, key_field: :alt_key_id
|
384
|
+
vault_attr :secret
|
385
|
+
end
|
354
386
|
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
k = key.to_json
|
364
|
-
Class.new(Sequel::Model(:items)) do
|
365
|
-
include AttrVault
|
366
|
-
vault_keyring k
|
367
|
-
vault_attr :secret, digest_field: :secret_digest
|
368
|
-
vault_attr :other, digest_field: :other_digest
|
387
|
+
secret = "up up down down left right left right b a"
|
388
|
+
s = item.create(secret: secret)
|
389
|
+
s.reload
|
390
|
+
expect(s.secret).to eq secret
|
391
|
+
expect(s.secret_encrypted).not_to eq secret
|
392
|
+
expect(s.alt_key_id).not_to be_nil
|
393
|
+
expect(s.key_id).to be_nil
|
394
|
+
end
|
369
395
|
end
|
370
|
-
end
|
371
396
|
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
397
|
+
context "with a digest field" do
|
398
|
+
let(:key_id) { 1 }
|
399
|
+
let(:key) do
|
400
|
+
[ { id: key_id,
|
401
|
+
value: 'aFJDXs+798G7wgS/nap21LXIpm/Rrr39jIVo2m/cdj8=' } ]
|
402
|
+
end
|
403
|
+
let(:item) do
|
404
|
+
k = key.to_json
|
405
|
+
Class.new(Sequel::Model(:items)) do
|
406
|
+
include AttrVault
|
407
|
+
vault_keyring k
|
408
|
+
vault_attr :secret, digest_field: :secret_digest
|
409
|
+
vault_attr :other, digest_field: :other_digest
|
410
|
+
end
|
411
|
+
end
|
376
412
|
|
377
|
-
|
378
|
-
|
379
|
-
|
413
|
+
def test_digest(key, data)
|
414
|
+
OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'),
|
415
|
+
key.first.fetch(:value), data)
|
416
|
+
end
|
380
417
|
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
expect(s.secret_digest).to eq(test_digest(key, secret))
|
385
|
-
expect(count_matching_digests(item, :secret_digest, secret)).to eq(1)
|
386
|
-
end
|
418
|
+
def count_matching_digests(item_class, digest_field, secret)
|
419
|
+
item.where({digest_field => item_class.vault_digests(secret)}).count
|
420
|
+
end
|
387
421
|
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
# Check vault_digests feature matching against the database.
|
396
|
-
expect(count_matching_digests(item, :secret_digest, secret)).to eq(1)
|
397
|
-
expect(count_matching_digests(item, :other_digest, other_secret)).to eq(1)
|
398
|
-
|
399
|
-
# Negative tests for mismatched digesting.
|
400
|
-
expect(count_matching_digests(item, :secret_digest, other_secret))
|
401
|
-
.to eq(0)
|
402
|
-
expect(count_matching_digests(item, :other_digest, secret)).to eq(0)
|
403
|
-
end
|
422
|
+
it "records the hmac of the plaintext value" do
|
423
|
+
secret = 'snape kills dumbledore'
|
424
|
+
s = item.create(secret: secret)
|
425
|
+
expect(s.secret_digest).to eq(test_digest(key, secret))
|
426
|
+
expect(count_matching_digests(item, :secret_digest, secret)).to eq(1)
|
427
|
+
end
|
404
428
|
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
429
|
+
it "can record multiple digest fields" do
|
430
|
+
secret = 'joffrey kills ned'
|
431
|
+
other_secret = '"gomer pyle" lawrence kills himself'
|
432
|
+
s = item.create(secret: secret, other: other_secret)
|
433
|
+
expect(s.secret_digest).to eq(test_digest(key, secret))
|
434
|
+
expect(s.other_digest).to eq(test_digest(key, other_secret))
|
435
|
+
|
436
|
+
# Check vault_digests feature matching against the database.
|
437
|
+
expect(count_matching_digests(item, :secret_digest, secret)).to eq(1)
|
438
|
+
expect(count_matching_digests(item, :other_digest, other_secret)).to eq(1)
|
439
|
+
|
440
|
+
# Negative tests for mismatched digesting.
|
441
|
+
expect(count_matching_digests(item, :secret_digest, other_secret))
|
442
|
+
.to eq(0)
|
443
|
+
expect(count_matching_digests(item, :other_digest, secret)).to eq(0)
|
444
|
+
end
|
410
445
|
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
end
|
417
|
-
end
|
446
|
+
it "records the digest for an empty field" do
|
447
|
+
s = item.create(secret: '', other: '')
|
448
|
+
expect(s.secret_digest).to eq(test_digest(key, ''))
|
449
|
+
expect(s.other_digest).to eq(test_digest(key, ''))
|
450
|
+
end
|
418
451
|
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
end
|
426
|
-
let(:item) do
|
427
|
-
k = key_data
|
428
|
-
Class.new(Sequel::Model(:items)) do
|
429
|
-
include AttrVault
|
430
|
-
vault_keyring k
|
431
|
-
vault_attr :secret
|
452
|
+
it "records the digest of a nil field" do
|
453
|
+
s = item.create
|
454
|
+
expect(s.secret_digest).to be_nil
|
455
|
+
expect(s.other_digest).to be_nil
|
456
|
+
end
|
457
|
+
end
|
432
458
|
end
|
433
|
-
end
|
434
459
|
|
435
|
-
|
436
|
-
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
460
|
+
describe "stress test" do
|
461
|
+
let(:key_id) { 1 }
|
462
|
+
let(:key_data) do
|
463
|
+
[ { id: key_id,
|
464
|
+
value: 'aFJDXs+798G7wgS/nap21LXIpm/Rrr39jIVo2m/cdj8=' } ].to_json
|
465
|
+
end
|
466
|
+
let(:item) do
|
467
|
+
k = key_data
|
468
|
+
Class.new(Sequel::Model(:items)) do
|
469
|
+
include AttrVault
|
470
|
+
vault_keyring k
|
471
|
+
vault_attr :secret
|
444
472
|
end
|
445
473
|
end
|
446
|
-
|
474
|
+
|
475
|
+
it "works" do
|
476
|
+
3.times.map do
|
477
|
+
Thread.new do
|
478
|
+
s = item.create(secret: 'that Commander Keen level in DOOM II')
|
479
|
+
1_000.times do
|
480
|
+
new_secret = [ nil, '', 36.times.map { (0..255).to_a.sample.chr }.join('') ].sample
|
481
|
+
s.update(secret: new_secret)
|
482
|
+
s.reload
|
483
|
+
expect(s.secret).to eq new_secret
|
484
|
+
end
|
485
|
+
end
|
486
|
+
end.map(&:join)
|
487
|
+
end
|
488
|
+
end
|
447
489
|
end
|
448
490
|
end
|
data/spec/spec_helper.rb
CHANGED
@@ -12,21 +12,36 @@ require 'pg'
|
|
12
12
|
require 'sequel'
|
13
13
|
|
14
14
|
conn = Sequel.connect(ENV['DATABASE_URL'])
|
15
|
-
conn.run 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'
|
16
|
-
conn.run 'DROP TABLE IF EXISTS items'
|
17
15
|
conn.run <<-EOF
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
16
|
+
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
17
|
+
|
18
|
+
DROP TABLE IF EXISTS items;
|
19
|
+
DROP TABLE IF EXISTS items_legacy;
|
20
|
+
|
21
|
+
CREATE TABLE items(
|
22
|
+
id serial primary key,
|
23
|
+
key_id integer,
|
24
|
+
alt_key_id integer,
|
25
|
+
secret_encrypted bytea,
|
26
|
+
secret_digest bytea,
|
27
|
+
other_encrypted bytea,
|
28
|
+
other_digest bytea,
|
29
|
+
not_secret text,
|
30
|
+
other_not_secret text
|
31
|
+
);
|
32
|
+
|
33
|
+
CREATE TABLE items_legacy(
|
34
|
+
id serial primary key,
|
35
|
+
key_id uuid,
|
36
|
+
alt_key_id uuid,
|
37
|
+
secret_encrypted bytea,
|
38
|
+
secret_digest bytea,
|
39
|
+
other_encrypted bytea,
|
40
|
+
other_digest bytea,
|
41
|
+
not_secret text,
|
42
|
+
other_not_secret text
|
43
|
+
);
|
44
|
+
EOF
|
30
45
|
|
31
46
|
RSpec.configure do |config|
|
32
47
|
config.run_all_when_everything_filtered = true
|
@@ -34,6 +49,7 @@ RSpec.configure do |config|
|
|
34
49
|
|
35
50
|
config.before(:example) do
|
36
51
|
conn.run 'TRUNCATE items'
|
52
|
+
conn.run 'TRUNCATE items_legacy'
|
37
53
|
end
|
38
54
|
|
39
55
|
# Run specs in random order to surface order dependencies. If you find an
|
metadata
CHANGED
@@ -1,57 +1,57 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: attr_vault
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Maciek Sakrejda
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2016-
|
11
|
+
date: 2016-07-18 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name:
|
14
|
+
name: pg
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
17
|
- - "~>"
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version:
|
20
|
-
type: :
|
19
|
+
version: 0.18.3
|
20
|
+
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
24
|
- - "~>"
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version:
|
26
|
+
version: 0.18.3
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
|
-
name:
|
28
|
+
name: sequel
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
30
30
|
requirements:
|
31
31
|
- - "~>"
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version:
|
34
|
-
type: :
|
33
|
+
version: '4.13'
|
34
|
+
type: :runtime
|
35
35
|
prerelease: false
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
37
37
|
requirements:
|
38
38
|
- - "~>"
|
39
39
|
- !ruby/object:Gem::Version
|
40
|
-
version:
|
40
|
+
version: '4.13'
|
41
41
|
- !ruby/object:Gem::Dependency
|
42
|
-
name:
|
42
|
+
name: rspec
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
44
44
|
requirements:
|
45
45
|
- - "~>"
|
46
46
|
- !ruby/object:Gem::Version
|
47
|
-
version: '
|
47
|
+
version: '3.0'
|
48
48
|
type: :development
|
49
49
|
prerelease: false
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
51
51
|
requirements:
|
52
52
|
- - "~>"
|
53
53
|
- !ruby/object:Gem::Version
|
54
|
-
version: '
|
54
|
+
version: '3.0'
|
55
55
|
description: Encryption at rest made easy
|
56
56
|
email:
|
57
57
|
- m.sakrejda@gmail.com
|