attr_vault 0.1.2 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|