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.
- checksums.yaml +7 -0
- data/README.md +128 -121
- data/bin/tome +5 -5
- data/lib/tome.rb +6 -6
- data/lib/tome/command.rb +460 -440
- data/lib/tome/crypt.rb +56 -56
- data/lib/tome/padding.rb +15 -15
- data/lib/tome/tome.rb +327 -327
- data/lib/tome/usage.rb +209 -209
- data/lib/tome/version.rb +3 -1
- metadata +17 -30
data/lib/tome/crypt.rb
CHANGED
@@ -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
|
data/lib/tome/padding.rb
CHANGED
@@ -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
|
data/lib/tome/tome.rb
CHANGED
@@ -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
|