tome 1.0.1 → 1.0.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,56 +1,56 @@
1
- require 'openssl'
2
- require 'securerandom'
3
-
4
- module Tome
5
- class Crypt
6
- def self.encrypt(opts = {})
7
- crypt :encrypt, opts
8
- end
9
-
10
- def self.decrypt(opts = {})
11
- crypt :decrypt, opts
12
- end
13
-
14
- def self.new_iv
15
- new_cipher.random_iv
16
- end
17
-
18
- def self.new_salt
19
- SecureRandom.uuid
20
- end
21
-
22
- private
23
- def self.new_cipher
24
- OpenSSL::Cipher::AES.new(256, :CBC)
25
- end
26
-
27
- def self.crypt(method, opts)
28
- raise ArgumentError if
29
- opts.nil? || opts.empty? || opts[:value].nil? ||
30
- opts[:password].nil? || opts[:password].empty? ||
31
- opts[:salt].nil? || opts[:salt].empty? ||
32
- opts[:iv].nil? || opts[:iv].empty? ||
33
- opts[:stretch].nil? || opts[:stretch].nil?
34
-
35
- cipher = new_cipher
36
- cipher.send(method)
37
-
38
- cipher.key = crypt_key(opts)
39
- cipher.iv = opts[:iv]
40
-
41
- result = cipher.update(opts[:value])
42
- result << cipher.final
43
- return result
44
- end
45
-
46
- def self.crypt_key(opts)
47
- password = opts[:password]
48
- salt = opts[:salt]
49
- iterations = opts[:stretch]
50
- key_length = 32 # 256 bits
51
- hash = OpenSSL::Digest::SHA512.new
52
-
53
- return OpenSSL::PKCS5.pbkdf2_hmac(password, salt, iterations, key_length, hash)
54
- end
55
- end
56
- end
1
+ require 'openssl'
2
+ require 'securerandom'
3
+
4
+ module Tome
5
+ class Crypt
6
+ def self.encrypt(opts = {})
7
+ crypt :encrypt, opts
8
+ end
9
+
10
+ def self.decrypt(opts = {})
11
+ crypt :decrypt, opts
12
+ end
13
+
14
+ def self.new_iv
15
+ new_cipher.random_iv
16
+ end
17
+
18
+ def self.new_salt
19
+ SecureRandom.uuid
20
+ end
21
+
22
+ private
23
+ def self.new_cipher
24
+ OpenSSL::Cipher::AES.new(256, :CBC)
25
+ end
26
+
27
+ def self.crypt(method, opts)
28
+ raise ArgumentError if
29
+ opts.nil? || opts.empty? || opts[:value].nil? ||
30
+ opts[:password].nil? || opts[:password].empty? ||
31
+ opts[:salt].nil? || opts[:salt].empty? ||
32
+ opts[:iv].nil? || opts[:iv].empty? ||
33
+ opts[:stretch].nil? || opts[:stretch].nil?
34
+
35
+ cipher = new_cipher
36
+ cipher.send(method)
37
+
38
+ cipher.key = crypt_key(opts)
39
+ cipher.iv = opts[:iv]
40
+
41
+ result = cipher.update(opts[:value])
42
+ result << cipher.final
43
+ return result
44
+ end
45
+
46
+ def self.crypt_key(opts)
47
+ password = opts[:password]
48
+ salt = opts[:salt]
49
+ iterations = opts[:stretch]
50
+ key_length = 32 # 256 bits
51
+ hash = OpenSSL::Digest::SHA512.new
52
+
53
+ return OpenSSL::PKCS5.pbkdf2_hmac(password, salt, iterations, key_length, hash)
54
+ end
55
+ end
56
+ end
@@ -1,16 +1,16 @@
1
- require 'yaml'
2
- require 'securerandom'
3
-
4
- module Tome
5
- class Padding
6
- def self.pad(value, min_pad, max_pad)
7
- padding = Random.rand(min_pad..max_pad)
8
- YAML.dump(:value => value, :padding => SecureRandom.random_bytes(padding))
9
- end
10
-
11
- def self.unpad(inflated_value)
12
- yaml = YAML.load(inflated_value)
13
- yaml[:value]
14
- end
15
- end
1
+ require 'yaml'
2
+ require 'securerandom'
3
+
4
+ module Tome
5
+ class Padding
6
+ def self.pad(value, min_pad, max_pad)
7
+ padding = Random.rand(min_pad..max_pad)
8
+ YAML.dump(:value => value, :padding => SecureRandom.random_bytes(padding))
9
+ end
10
+
11
+ def self.unpad(inflated_value)
12
+ yaml = YAML.load(inflated_value)
13
+ yaml[:value]
14
+ end
15
+ end
16
16
  end
@@ -1,327 +1,327 @@
1
- require 'yaml'
2
- require 'fileutils'
3
-
4
- module Tome
5
- class MasterPasswordError < RuntimeError
6
- end
7
-
8
- class FileFormatError < RuntimeError
9
- end
10
-
11
- class Tome
12
- def self.exists?(tome_filename)
13
- return !load_tome(tome_filename).nil?
14
- end
15
-
16
- def self.create!(tome_filename, master_password, stretch = 100_000)
17
- if tome_filename.nil? || tome_filename.empty?
18
- raise ArgumentError
19
- end
20
-
21
- if master_password.nil? || master_password.empty?
22
- raise MasterPasswordError
23
- end
24
-
25
- save_tome(tome_filename, new_tome(stretch), {}, master_password)
26
- return Tome.new(tome_filename, master_password)
27
- end
28
-
29
- def initialize(tome_filename, master_password)
30
- if tome_filename.nil? || tome_filename.empty?
31
- raise ArgumentError
32
- end
33
-
34
- if master_password.nil? || master_password.empty?
35
- raise MasterPasswordError
36
- end
37
-
38
- @tome_filename = tome_filename
39
- @master_password = master_password
40
-
41
- # TODO: This is suboptimal. We are loading the store
42
- # twice for most operations because of this authentication.
43
- authenticate()
44
- end
45
-
46
- def set(id, password)
47
- if id.nil? || id.empty? || password.nil? || password.empty?
48
- raise ArgumentError
49
- end
50
-
51
- return writable_store do |store|
52
- set_by_id(store, id, password)
53
- end
54
- end
55
-
56
- def get(id)
57
- if id.nil? || id.empty?
58
- raise ArgumentError
59
- end
60
-
61
- return readable_store do |store|
62
- get_by_id(store, id)
63
- end
64
- end
65
-
66
- def find(pattern)
67
- if pattern.nil? || pattern.empty?
68
- raise ArgumentError
69
- end
70
-
71
- return readable_store do |store|
72
- get_by_pattern(store, pattern)
73
- end
74
- end
75
-
76
- def delete(id)
77
- if id.nil? || id.empty?
78
- raise ArgumentError
79
- end
80
-
81
- return writable_store do |store|
82
- delete_by_id(store, id)
83
- end
84
- end
85
-
86
- def rename(old_id, new_id)
87
- if old_id.nil? || old_id.empty? || new_id.nil? || new_id.empty?
88
- raise ArgumentError
89
- end
90
-
91
- return writable_store do |store|
92
- rename_by_id(store, old_id, new_id)
93
- end
94
- end
95
-
96
- def each_password
97
- if !block_given?
98
- raise ArgumentError
99
- end
100
-
101
- readable_store do |store|
102
- store.each { |id, info|
103
- yield id, info[:password]
104
- }
105
- end
106
- end
107
-
108
- def master_password=(master_password)
109
- return writable_store do |store|
110
- @master_password = master_password
111
- true
112
- end
113
- end
114
-
115
- private
116
- def set_by_id(store, id, password)
117
- created = !store.include?(id)
118
-
119
- store[id] = {}
120
- store[id][:password] = password
121
-
122
- return created
123
- end
124
-
125
- def get_by_pattern(store, pattern)
126
- find_by_pattern(store, pattern).map { |key, value|
127
- { key => value[:password] }
128
- }.inject { |hash, item|
129
- hash.merge!(item)
130
- } || {}
131
- end
132
-
133
- def get_by_id(store, id)
134
- store[id]
135
- end
136
-
137
- def delete_by_id(store, id)
138
- same = store.reject! { |key, info|
139
- key.casecmp(id) == 0
140
- }.nil?
141
-
142
- return !same
143
- end
144
-
145
- def find_by_pattern(store, pattern)
146
- return {} if pattern.nil?
147
-
148
- # TODO: Better matching. Should allow separated
149
- # substring matching. Exact match > solid substrings > separated substrings.
150
-
151
- exact = store.select { |key, info|
152
- key.casecmp(pattern) == 0
153
- }
154
-
155
- return exact if !exact.empty?
156
-
157
- return store.select { |key, info|
158
- key =~ /#{pattern}/i
159
- }
160
- end
161
-
162
- def rename_by_id(store, old_id, new_id)
163
- if store[old_id].nil?
164
- return false
165
- else
166
- values = store[old_id]
167
- store.delete(old_id)
168
- store[new_id] = values
169
- return true
170
- end
171
- end
172
-
173
- def self.load_tome(tome_filename)
174
- if tome_filename.nil? || tome_filename.empty?
175
- raise ArgumentError
176
- end
177
-
178
- return nil if !File.exist?(tome_filename)
179
-
180
- contents = File.open(tome_filename, 'rb') { |file| file.read }
181
- return nil if contents.length == 0
182
-
183
- tome = YAML.load(contents)
184
- return nil if !tome
185
-
186
- validate_tome(tome)
187
-
188
- return tome
189
- end
190
-
191
- def self.validate_tome(tome)
192
- if tome[:version].nil? || tome[:version].class != Fixnum
193
- raise FileFormatError, 'The tome database is invalid (missing or invalid version).'
194
- end
195
-
196
- if tome[:version] > FILE_VERSION
197
- raise FileFormatError, "The tome database comes from a newer version of tome (v#{tome[:version]} > v#{FILE_VERSION}). Try updating tome."
198
- end
199
-
200
- if tome[:version] < FILE_VERSION
201
- raise FileFormatError, "The tome database is incompatible with this version of tome (v#{tome[:version]} < v#{FILE_VERSION})."
202
- end
203
-
204
- # TODO: Check version number, do file format migration if necessary.
205
-
206
- if tome[:salt].nil? || tome[:salt].class != String || tome[:salt].empty?
207
- raise FileFormatError, 'The tome database is invalid (missing or invalid salt).'
208
- end
209
-
210
- if tome[:iv].nil? || tome[:iv].class != String || tome[:iv].empty?
211
- raise FileFormatError, 'The tome database is invalid (missing or invalid IV).'
212
- end
213
-
214
- if tome[:stretch].nil? || tome[:stretch].class != Fixnum || tome[:stretch] < 0
215
- raise FileFormatError, 'The tome database is invalid (missing or invalid key stretch).'
216
- end
217
-
218
- if tome[:store].nil? || tome[:store].class != String || tome[:store].empty?
219
- raise FileFormatError, 'The tome database is invalid (missing or invalid store).'
220
- end
221
- end
222
-
223
- def load_store(tome)
224
- if tome.nil?
225
- raise ArgumentError
226
- end
227
-
228
- begin
229
- padded_store_yaml = Crypt.decrypt(
230
- :value => tome[:store],
231
- :password => @master_password,
232
- :stretch => tome[:stretch],
233
- :salt => tome[:salt],
234
- :iv => tome[:iv]
235
- )
236
- rescue OpenSSL::Cipher::CipherError
237
- raise MasterPasswordError
238
- end
239
-
240
- begin
241
- store_yaml = Padding.unpad(padded_store_yaml)
242
- rescue Exception
243
- raise MasterPasswordError
244
- end
245
-
246
- store = YAML.load(store_yaml)
247
- return store || {}
248
- end
249
-
250
- def self.save_tome(tome_filename, tome, store, master_password)
251
- if tome.nil? || store.nil? || master_password.nil? || master_password.empty?
252
- raise ArgumentError
253
- end
254
-
255
- store_yaml = YAML.dump(store)
256
- padded_store_yaml = Padding.pad(store_yaml, 1024, 4096)
257
-
258
- new_salt = Crypt.new_salt
259
- new_iv = Crypt.new_iv
260
-
261
- encrypted_store = Crypt.encrypt(
262
- :value => padded_store_yaml,
263
- :password => master_password,
264
- :salt => new_salt,
265
- :iv => new_iv,
266
- :stretch => tome[:stretch]
267
- )
268
-
269
- contents = tome.merge({
270
- :version => FILE_VERSION,
271
- :store => encrypted_store,
272
- :salt => new_salt,
273
- :iv => new_iv
274
- })
275
-
276
- File.open(tome_filename, 'wb') do |file|
277
- YAML.dump(contents, file)
278
- end
279
- end
280
-
281
- def readable_store()
282
- tome = Tome.load_tome(@tome_filename)
283
- store = load_store(tome)
284
-
285
- begin
286
- result = yield store
287
- ensure
288
- store = nil
289
- GC.start
290
- end
291
-
292
- return result
293
- end
294
-
295
- def writable_store()
296
- tome = Tome.load_tome(@tome_filename)
297
- store = load_store(tome)
298
-
299
- begin
300
- result = yield store
301
- Tome.save_tome(@tome_filename, tome, store, @master_password)
302
- ensure
303
- store = nil
304
- GC.start
305
- end
306
-
307
- return result
308
- end
309
-
310
- def self.new_tome(stretch)
311
- {
312
- :store => {},
313
- :salt => Crypt.new_salt,
314
- :iv => Crypt.new_iv,
315
- :stretch => stretch
316
- }
317
- end
318
-
319
- def authenticate
320
- # Force a read.
321
- # If the master password is invalid, the access exception will propagate.
322
- readable_store { }
323
- end
324
-
325
- FILE_VERSION = 2
326
- end
327
- end
1
+ require 'yaml'
2
+ require 'fileutils'
3
+
4
+ module Tome
5
+ class MasterPasswordError < RuntimeError
6
+ end
7
+
8
+ class FileFormatError < RuntimeError
9
+ end
10
+
11
+ class Tome
12
+ def self.exists?(tome_filename)
13
+ return !load_tome(tome_filename).nil?
14
+ end
15
+
16
+ def self.create!(tome_filename, master_password, stretch = 100_000)
17
+ if tome_filename.nil? || tome_filename.empty?
18
+ raise ArgumentError
19
+ end
20
+
21
+ if master_password.nil? || master_password.empty?
22
+ raise MasterPasswordError
23
+ end
24
+
25
+ save_tome(tome_filename, new_tome(stretch), {}, master_password)
26
+ return Tome.new(tome_filename, master_password)
27
+ end
28
+
29
+ def initialize(tome_filename, master_password)
30
+ if tome_filename.nil? || tome_filename.empty?
31
+ raise ArgumentError
32
+ end
33
+
34
+ if master_password.nil? || master_password.empty?
35
+ raise MasterPasswordError
36
+ end
37
+
38
+ @tome_filename = tome_filename
39
+ @master_password = master_password
40
+
41
+ # TODO: This is suboptimal. We are loading the store
42
+ # twice for most operations because of this authentication.
43
+ authenticate()
44
+ end
45
+
46
+ def set(id, password)
47
+ if id.nil? || id.empty? || password.nil? || password.empty?
48
+ raise ArgumentError
49
+ end
50
+
51
+ return writable_store do |store|
52
+ set_by_id(store, id, password)
53
+ end
54
+ end
55
+
56
+ def get(id)
57
+ if id.nil? || id.empty?
58
+ raise ArgumentError
59
+ end
60
+
61
+ return readable_store do |store|
62
+ get_by_id(store, id)
63
+ end
64
+ end
65
+
66
+ def find(pattern)
67
+ if pattern.nil? || pattern.empty?
68
+ raise ArgumentError
69
+ end
70
+
71
+ return readable_store do |store|
72
+ get_by_pattern(store, pattern)
73
+ end
74
+ end
75
+
76
+ def delete(id)
77
+ if id.nil? || id.empty?
78
+ raise ArgumentError
79
+ end
80
+
81
+ return writable_store do |store|
82
+ delete_by_id(store, id)
83
+ end
84
+ end
85
+
86
+ def rename(old_id, new_id)
87
+ if old_id.nil? || old_id.empty? || new_id.nil? || new_id.empty?
88
+ raise ArgumentError
89
+ end
90
+
91
+ return writable_store do |store|
92
+ rename_by_id(store, old_id, new_id)
93
+ end
94
+ end
95
+
96
+ def each_password
97
+ if !block_given?
98
+ raise ArgumentError
99
+ end
100
+
101
+ readable_store do |store|
102
+ store.each { |id, info|
103
+ yield id, info[:password]
104
+ }
105
+ end
106
+ end
107
+
108
+ def master_password=(master_password)
109
+ return writable_store do |store|
110
+ @master_password = master_password
111
+ true
112
+ end
113
+ end
114
+
115
+ private
116
+ def set_by_id(store, id, password)
117
+ created = !store.include?(id)
118
+
119
+ store[id] = {}
120
+ store[id][:password] = password
121
+
122
+ return created
123
+ end
124
+
125
+ def get_by_pattern(store, pattern)
126
+ find_by_pattern(store, pattern).map { |key, value|
127
+ { key => value[:password] }
128
+ }.inject { |hash, item|
129
+ hash.merge!(item)
130
+ } || {}
131
+ end
132
+
133
+ def get_by_id(store, id)
134
+ store[id]
135
+ end
136
+
137
+ def delete_by_id(store, id)
138
+ same = store.reject! { |key, info|
139
+ key.casecmp(id) == 0
140
+ }.nil?
141
+
142
+ return !same
143
+ end
144
+
145
+ def find_by_pattern(store, pattern)
146
+ return {} if pattern.nil?
147
+
148
+ # TODO: Better matching. Should allow separated
149
+ # substring matching. Exact match > solid substrings > separated substrings.
150
+
151
+ exact = store.select { |key, info|
152
+ key.casecmp(pattern) == 0
153
+ }
154
+
155
+ return exact if !exact.empty?
156
+
157
+ return store.select { |key, info|
158
+ key =~ /#{pattern}/i
159
+ }
160
+ end
161
+
162
+ def rename_by_id(store, old_id, new_id)
163
+ if store[old_id].nil?
164
+ return false
165
+ else
166
+ values = store[old_id]
167
+ store.delete(old_id)
168
+ store[new_id] = values
169
+ return true
170
+ end
171
+ end
172
+
173
+ def self.load_tome(tome_filename)
174
+ if tome_filename.nil? || tome_filename.empty?
175
+ raise ArgumentError
176
+ end
177
+
178
+ return nil if !File.exist?(tome_filename)
179
+
180
+ contents = File.open(tome_filename, 'rb') { |file| file.read }
181
+ return nil if contents.length == 0
182
+
183
+ tome = YAML.load(contents)
184
+ return nil if !tome
185
+
186
+ validate_tome(tome)
187
+
188
+ return tome
189
+ end
190
+
191
+ def self.validate_tome(tome)
192
+ if tome[:version].nil? || tome[:version].class != Fixnum
193
+ raise FileFormatError, 'The tome database is invalid (missing or invalid version).'
194
+ end
195
+
196
+ if tome[:version] > FILE_VERSION
197
+ raise FileFormatError, "The tome database comes from a newer version of tome (v#{tome[:version]} > v#{FILE_VERSION}). Try updating tome."
198
+ end
199
+
200
+ if tome[:version] < FILE_VERSION
201
+ raise FileFormatError, "The tome database is incompatible with this version of tome (v#{tome[:version]} < v#{FILE_VERSION})."
202
+ end
203
+
204
+ # TODO: Check version number, do file format migration if necessary.
205
+
206
+ if tome[:salt].nil? || tome[:salt].class != String || tome[:salt].empty?
207
+ raise FileFormatError, 'The tome database is invalid (missing or invalid salt).'
208
+ end
209
+
210
+ if tome[:iv].nil? || tome[:iv].class != String || tome[:iv].empty?
211
+ raise FileFormatError, 'The tome database is invalid (missing or invalid IV).'
212
+ end
213
+
214
+ if tome[:stretch].nil? || tome[:stretch].class != Fixnum || tome[:stretch] < 0
215
+ raise FileFormatError, 'The tome database is invalid (missing or invalid key stretch).'
216
+ end
217
+
218
+ if tome[:store].nil? || tome[:store].class != String || tome[:store].empty?
219
+ raise FileFormatError, 'The tome database is invalid (missing or invalid store).'
220
+ end
221
+ end
222
+
223
+ def load_store(tome)
224
+ if tome.nil?
225
+ raise ArgumentError
226
+ end
227
+
228
+ begin
229
+ padded_store_yaml = Crypt.decrypt(
230
+ :value => tome[:store],
231
+ :password => @master_password,
232
+ :stretch => tome[:stretch],
233
+ :salt => tome[:salt],
234
+ :iv => tome[:iv]
235
+ )
236
+ rescue OpenSSL::Cipher::CipherError
237
+ raise MasterPasswordError
238
+ end
239
+
240
+ begin
241
+ store_yaml = Padding.unpad(padded_store_yaml)
242
+ rescue Exception
243
+ raise MasterPasswordError
244
+ end
245
+
246
+ store = YAML.load(store_yaml)
247
+ return store || {}
248
+ end
249
+
250
+ def self.save_tome(tome_filename, tome, store, master_password)
251
+ if tome.nil? || store.nil? || master_password.nil? || master_password.empty?
252
+ raise ArgumentError
253
+ end
254
+
255
+ store_yaml = YAML.dump(store)
256
+ padded_store_yaml = Padding.pad(store_yaml, 1024, 4096)
257
+
258
+ new_salt = Crypt.new_salt
259
+ new_iv = Crypt.new_iv
260
+
261
+ encrypted_store = Crypt.encrypt(
262
+ :value => padded_store_yaml,
263
+ :password => master_password,
264
+ :salt => new_salt,
265
+ :iv => new_iv,
266
+ :stretch => tome[:stretch]
267
+ )
268
+
269
+ contents = tome.merge({
270
+ :version => FILE_VERSION,
271
+ :store => encrypted_store,
272
+ :salt => new_salt,
273
+ :iv => new_iv
274
+ })
275
+
276
+ File.open(tome_filename, 'wb') do |file|
277
+ YAML.dump(contents, file)
278
+ end
279
+ end
280
+
281
+ def readable_store()
282
+ tome = Tome.load_tome(@tome_filename)
283
+ store = load_store(tome)
284
+
285
+ begin
286
+ result = yield store
287
+ ensure
288
+ store = nil
289
+ GC.start
290
+ end
291
+
292
+ return result
293
+ end
294
+
295
+ def writable_store()
296
+ tome = Tome.load_tome(@tome_filename)
297
+ store = load_store(tome)
298
+
299
+ begin
300
+ result = yield store
301
+ Tome.save_tome(@tome_filename, tome, store, @master_password)
302
+ ensure
303
+ store = nil
304
+ GC.start
305
+ end
306
+
307
+ return result
308
+ end
309
+
310
+ def self.new_tome(stretch)
311
+ {
312
+ :store => {},
313
+ :salt => Crypt.new_salt,
314
+ :iv => Crypt.new_iv,
315
+ :stretch => stretch
316
+ }
317
+ end
318
+
319
+ def authenticate
320
+ # Force a read.
321
+ # If the master password is invalid, the access exception will propagate.
322
+ readable_store { }
323
+ end
324
+
325
+ FILE_VERSION = 2
326
+ end
327
+ end