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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 86139c030cc0889d40ee3b40db69fe0b8b66beee
4
- data.tar.gz: cadcf1eabcf1dc445edc9d8d423f66ff6cb58242
3
+ metadata.gz: 679c27532f9ed523f12ffad0a4ac79b7d70b95a3
4
+ data.tar.gz: 6fb6564391b43997048851d9ab6a706c98b9ee27
5
5
  SHA512:
6
- metadata.gz: 4758d09746a415b03c7b2e187419501f78a3815b67a918e810c35b7cf6d90319f9fdcf32ce100bf8b48b25453a5831c11b8d98c446c80caef47b6cd5d9d07a14
7
- data.tar.gz: bfca3f4fc0f177d8e4244e6e778b7f4d3a4599b9aab9a0f68e225e8ba4bfd22388938d75869a49ef31cc724160340aafb563c8309b2018976c723f094515bc7d
6
+ metadata.gz: 4904567a0e64825c0396ffe7bfe4a6e5915b267ba083091d0e383cbc6226c07e9fa0952d24a231588da7ec1f9243cd2b0e01ec7b4ce73ae4bb490958f22ba2bd
7
+ data.tar.gz: 2979e267a1e776842472f9a8d86fb6125c549ef1fe08c9546b49869309f8afcdbcb62a8288e92ab443fbd10fc94921d547cbe6a5773c5d5eb737301766c71a8c
data/Gemfile.lock CHANGED
@@ -1,7 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- attr_vault (0.1.2)
4
+ attr_vault (0.2.0)
5
+ pg (~> 0.18.3)
6
+ sequel (~> 4.13)
5
7
 
6
8
  GEM
7
9
  remote: https://rubygems.org/
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 containing
36
- information about your encryption keys. The keyring must be a JSON
37
- array of objects with the fields `id`, `created_at`, and `value`. A
38
- keyring must have at least one key.
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
- The `id` can be numeric or a uuid. The `created_at` must be an
41
- ISO-8601 timestamp indicating the age of a key relative to the other
42
- keys. The `value` is the actual bytes of the encryption key, used for
43
- encryption and verification: see below.
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, :uuid
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 more recent `created_at` than any other key, that key
178
- will automatically be used for encryption. Any keys that are no longer
179
- in use can be removed from the keyring.
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
@@ -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
- if created_at.nil?
13
- raise InvalidKey, "key created_at required"
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
- unless candidate_keys.respond_to? :each
38
- raise InvalidKeyring, "does not respond to each"
39
- end
40
- candidate_keys.each_with_index do |k|
41
- created_at = unless k["created_at"].nil?
42
- Time.parse(k["created_at"])
43
- end
44
- keyring.add_key(Key.new(k["id"], k["value"], created_at || Time.now))
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.to_json
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
@@ -1,3 +1,3 @@
1
1
  module AttrVault
2
- VERSION = "0.1.2"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -2,447 +2,489 @@ require 'spec_helper'
2
2
  require 'json'
3
3
 
4
4
  describe AttrVault do
5
- context "with a single encrypted column" do
6
- let(:key_id) { '80a8571b-dc8a-44da-9b89-caee87e41ce2' }
7
- let(:key_data) do
8
- [ { id: key_id,
9
- value: 'aFJDXs+798G7wgS/nap21LXIpm/Rrr39jIVo2m/cdj8=',
10
- created_at: Time.now } ].to_json
11
- end
12
- let(:item) do
13
- k = key_data
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
- it "encrypts non-empty values" do
31
- secret = 'lady gaga? also rather talented'
32
- s = item.create(secret: secret)
33
- s.reload
34
- expect(s.secret).to eq(secret)
35
- s.columns.each do |col|
36
- expect(s.this.where(Sequel.cast(Sequel.cast(col, :text), :bytea) => secret).count).to eq 0
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
- it "stores empty values as empty" do
41
- secret = ''
42
- s = item.create(secret: secret)
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
- it "sets fields to empty that were previously not empty" do
56
- s = item.create(secret: 'joyce hatto')
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
- it "avoids updating existing values when those do not change" do
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
- it "encrypts non-empty values" do
91
- secret = 'soylent green is made of people'
92
- s = item.create
93
- s.update(secret: secret)
94
- s.reload
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
- it "stores empty values as empty" do
102
- s = item.create(secret: "darth vader is luke's father")
103
- s.update(secret: '')
104
- s.reload
105
- expect(s.secret).to eq('')
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
- it "reads the correct value for a dirty field before the object is saved" do
139
- s = item.create
140
- secret = 'mcmurphy is lobotomized =('
141
- s.secret = secret
142
- expect(s.secret).to eq secret
143
- end
144
- end
145
- end
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
- context "with multiple encrypted columns" do
148
- let(:key_data) do
149
- [ { id: '80a8571b-dc8a-44da-9b89-caee87e41ce2',
150
- value: 'aFJDXs+798G7wgS/nap21LXIpm/Rrr39jIVo2m/cdj8=',
151
- created_at: Time.now } ].to_json
152
- end
153
- let(:item) do
154
- k = key_data
155
- Class.new(Sequel::Model(:items)) do
156
- include AttrVault
157
- vault_keyring k
158
- vault_attr :secret
159
- vault_attr :other
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
- it "does not clobber other attributes" do
164
- secret1 = "superman is really mild-mannered reporter clark kent"
165
- secret2 = "batman is really millionaire playboy bruce wayne"
166
- s = item.create(secret: secret1)
167
- s.reload
168
- expect(s.secret).to eq secret1
169
- s.update(other: secret2)
170
- s.reload
171
- expect(s.secret).to eq secret1
172
- expect(s.other).to eq secret2
173
- end
174
- end
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
- context "with items encrypted with an older key" do
177
- let(:key1_id) { '80a8571b-dc8a-44da-9b89-caee87e41ce2' }
178
- let(:key1) do
179
- { id: key1_id,
180
- value: 'aFJDXs+798G7wgS/nap21LXIpm/Rrr39jIVo2m/cdj8=',
181
- created_at: Time.new(2014, 1, 1, 0, 0, 0) }
182
- end
183
- let(:key2_id) { '0a85781b-d8ac-4a4d-89b9-acee874e1ec2' }
184
- let(:key2) do
185
- { id: key2_id,
186
- value: 'hUL1orBBRckZOuSuptRXYMV9lx5Qp54zwFUVwpwTpdk=',
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
- it "rewrites the items using the current key" do
211
- secret1 = 'mrs. doubtfire is really a man'
212
- secret2 = 'tootsie? also a man'
213
- record = item1.create(secret: secret1)
214
- expect(record.key_id).to eq key1_id
215
- expect(record.secret).to eq secret1
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
- old_secret_encrypted = record.secret_encrypted
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
- new_key_record = item2[record.id]
220
- new_key_record.update(secret: secret2)
221
- new_key_record.reload
261
+ old_secret_encrypted = record.secret_encrypted
222
262
 
223
- expect(new_key_record.key_id).to eq key2_id
224
- expect(new_key_record.secret).to eq secret2
225
- expect(new_key_record.secret_encrypted).not_to eq old_secret_encrypted
226
- end
263
+ new_key_record = item2[record.id]
264
+ new_key_record.update(secret: secret2)
265
+ new_key_record.reload
227
266
 
228
- it "rewrites the items using the current key even if they are not updated" do
229
- secret1 = 'the planet of the apes is really earth'
230
- secret2 = 'the answer is 42'
231
- record = item1.create(secret: secret1)
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
- old_secret_encrypted = record.secret_encrypted
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
- new_key_record = item2[record.id]
238
- new_key_record.update(other: secret2)
239
- new_key_record.reload
279
+ old_secret_encrypted = record.secret_encrypted
240
280
 
241
- expect(new_key_record.key_id).to eq key2_id
242
- expect(new_key_record.secret).to eq secret1
243
- expect(new_key_record.secret_encrypted).not_to eq old_secret_encrypted
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
- context "with plaintext source fields" do
249
- let(:key_id) { '80a8571b-dc8a-44da-9b89-caee87e41ce2' }
250
- let(:key_data) do
251
- [ { id: key_id,
252
- value: 'aFJDXs+798G7wgS/nap21LXIpm/Rrr39jIVo2m/cdj8=',
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
- it "copies a plaintext field to an encrypted field when saving the object" do
275
- becomes_secret = 'the location of the lost continent of atlantis'
276
- s = item1.create(not_secret: becomes_secret)
277
- reloaded = item2[s.id]
278
- expect(reloaded.not_secret).to eq becomes_secret
279
- reloaded.save
280
- reloaded.reload
281
- expect(reloaded.not_secret).to be_nil
282
- expect(reloaded.secret).to eq becomes_secret
283
- end
284
-
285
- it "supports converting multiple fields" do
286
- becomes_secret1 = 'the location of the fountain of youth'
287
- becomes_secret2 = 'the location of the lost city of el dorado'
288
- s = item1.create(not_secret: becomes_secret1, other_not_secret: becomes_secret2)
289
- reloaded = item2[s.id]
290
- expect(reloaded.not_secret).to eq becomes_secret1
291
- expect(reloaded.other_not_secret).to eq becomes_secret2
292
- reloaded.save
293
- reloaded.reload
294
- expect(reloaded.not_secret).to be_nil
295
- expect(reloaded.secret).to eq becomes_secret1
296
- expect(reloaded.other_not_secret).to be_nil
297
- expect(reloaded.other).to eq becomes_secret2
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
- it "nils out the plaintext field and persists the encrypted field on save" do
301
- becomes_secret = 'the location of all those socks that disappear from the dryer'
302
- new_secret = 'the location of pliny the younger drafts'
303
- s = item1.create(not_secret: becomes_secret)
304
- reloaded = item2[s.id]
305
- expect(reloaded.secret).to eq(becomes_secret)
306
- reloaded.secret = new_secret
307
- expect(reloaded.secret).to eq(new_secret)
308
- reloaded.save
309
- expect(reloaded.secret).to eq(new_secret)
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
- context "with renamed database fields" do
315
- let(:key_data) do
316
- [ { id: '80a8571b-dc8a-44da-9b89-caee87e41ce2',
317
- value: 'aFJDXs+798G7wgS/nap21LXIpm/Rrr39jIVo2m/cdj8=',
318
- created_at: Time.now } ].to_json
319
- end
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
- it "supports renaming the encrypted field" do
322
- k = key_data
323
- item = Class.new(Sequel::Model(:items)) do
324
- include AttrVault
325
- vault_keyring k
326
- vault_attr :classified_info,
327
- encrypted_field: :secret_encrypted
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
- secret = "we've secretly replaced the fine coffee they usually serve with Folgers Crystals"
331
- s = item.create(classified_info: secret)
332
- s.reload
333
- expect(s.classified_info).to eq secret
334
- expect(s.secret_encrypted).not_to eq secret
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
- it "supports renaming the key id field" do
338
- k = key_data
339
- item = Class.new(Sequel::Model(:items)) do
340
- include AttrVault
341
- vault_keyring k, key_field: :alt_key_id
342
- vault_attr :secret
343
- end
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
- secret = "up up down down left right left right b a"
346
- s = item.create(secret: secret)
347
- s.reload
348
- expect(s.secret).to eq secret
349
- expect(s.secret_encrypted).not_to eq secret
350
- expect(s.alt_key_id).not_to be_nil
351
- expect(s.key_id).to be_nil
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
- context "with a digest field" do
356
- let(:key_id) { '80a8571b-dc8a-44da-9b89-caee87e41ce2' }
357
- let(:key) do
358
- [ { id: key_id,
359
- value: 'aFJDXs+798G7wgS/nap21LXIpm/Rrr39jIVo2m/cdj8=',
360
- created_at: Time.now } ]
361
- end
362
- let(:item) do
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
- def test_digest(key, data)
373
- OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'),
374
- key.first.fetch(:value), data)
375
- end
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
- def count_matching_digests(item_class, digest_field, secret)
378
- item.where({digest_field => item_class.vault_digests(secret)}).count
379
- end
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
- it "records the hmac of the plaintext value" do
382
- secret = 'snape kills dumbledore'
383
- s = item.create(secret: secret)
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
- it "can record multiple digest fields" do
389
- secret = 'joffrey kills ned'
390
- other_secret = '"gomer pyle" lawrence kills himself'
391
- s = item.create(secret: secret, other: other_secret)
392
- expect(s.secret_digest).to eq(test_digest(key, secret))
393
- expect(s.other_digest).to eq(test_digest(key, other_secret))
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
- it "records the digest for an empty field" do
406
- s = item.create(secret: '', other: '')
407
- expect(s.secret_digest).to eq(test_digest(key, ''))
408
- expect(s.other_digest).to eq(test_digest(key, ''))
409
- end
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
- it "records the digest of a nil field" do
412
- s = item.create
413
- expect(s.secret_digest).to be_nil
414
- expect(s.other_digest).to be_nil
415
- end
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
- describe "stress test" do
420
- let(:key_id) { '80a8571b-dc8a-44da-9b89-caee87e41ce2' }
421
- let(:key_data) do
422
- [ { id: key_id,
423
- value: 'aFJDXs+798G7wgS/nap21LXIpm/Rrr39jIVo2m/cdj8=',
424
- created_at: Time.now } ].to_json
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
- it "works" do
436
- 3.times.map do
437
- Thread.new do
438
- s = item.create(secret: 'that Commander Keen level in DOOM II')
439
- 1_000.times do
440
- new_secret = [ nil, '', 36.times.map { (0..255).to_a.sample.chr }.join('') ].sample
441
- s.update(secret: new_secret)
442
- s.reload
443
- expect(s.secret).to eq new_secret
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
- end.map(&:join)
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
- CREATE TABLE items(
19
- id serial primary key,
20
- key_id uuid,
21
- alt_key_id uuid,
22
- secret_encrypted bytea,
23
- secret_digest bytea,
24
- other_encrypted bytea,
25
- other_digest bytea,
26
- not_secret text,
27
- other_not_secret text
28
- )
29
- EOF
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.1.2
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-01-22 00:00:00.000000000 Z
11
+ date: 2016-07-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: rspec
14
+ name: pg
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '3.0'
20
- type: :development
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: '3.0'
26
+ version: 0.18.3
27
27
  - !ruby/object:Gem::Dependency
28
- name: pg
28
+ name: sequel
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: 0.18.3
34
- type: :development
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: 0.18.3
40
+ version: '4.13'
41
41
  - !ruby/object:Gem::Dependency
42
- name: sequel
42
+ name: rspec
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '4.13'
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: '4.13'
54
+ version: '3.0'
55
55
  description: Encryption at rest made easy
56
56
  email:
57
57
  - m.sakrejda@gmail.com