tome 1.0.1 → 1.0.3

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.
@@ -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