mpw 4.0.0 → 4.1.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 +4 -4
- data/.rubocop.yml +122 -0
- data/.travis.yml +2 -0
- data/CHANGELOG.md +9 -0
- data/Gemfile +7 -9
- data/README.md +1 -7
- data/VERSION +1 -1
- data/bin/mpw +25 -25
- data/bin/mpw-add +54 -25
- data/bin/mpw-config +74 -62
- data/bin/mpw-copy +25 -30
- data/bin/mpw-delete +24 -29
- data/bin/mpw-export +27 -32
- data/bin/mpw-genpwd +22 -22
- data/bin/mpw-import +20 -25
- data/bin/mpw-list +24 -29
- data/bin/mpw-update +61 -32
- data/bin/mpw-wallet +48 -73
- data/i18n/en.yml +15 -26
- data/i18n/fr.yml +15 -26
- data/lib/mpw/cli.rb +554 -546
- data/lib/mpw/config.rb +168 -139
- data/lib/mpw/item.rb +75 -82
- data/lib/mpw/mpw.rb +328 -465
- data/mpw.gemspec +11 -10
- data/templates/add_form.erb +8 -8
- data/templates/update_form.erb +7 -7
- data/test/test_config.rb +70 -55
- data/test/test_item.rb +167 -167
- data/test/test_mpw.rb +132 -132
- data/test/test_translate.rb +23 -23
- data/test/tests.rb +1 -1
- metadata +4 -45
- data/lib/mpw/sync/ftp.rb +0 -68
- data/lib/mpw/sync/ssh.rb +0 -67
data/lib/mpw/mpw.rb
CHANGED
@@ -1,17 +1,17 @@
|
|
1
1
|
#!/usr/bin/ruby
|
2
2
|
# MPW is a software to crypt and manage your passwords
|
3
|
-
# Copyright (C)
|
4
|
-
#
|
3
|
+
# Copyright (C) 2017 Adrien Waksberg <mpw@yae.im>
|
4
|
+
#
|
5
5
|
# This program is free software; you can redistribute it and/or
|
6
6
|
# modify it under the terms of the GNU General Public License
|
7
7
|
# as published by the Free Software Foundation; either version 2
|
8
8
|
# of the License, or (at your option) any later version.
|
9
|
-
#
|
9
|
+
#
|
10
10
|
# This program is distributed in the hope that it will be useful,
|
11
11
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
12
12
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
13
13
|
# GNU General Public License for more details.
|
14
|
-
#
|
14
|
+
#
|
15
15
|
# You should have received a copy of the GNU General Public License
|
16
16
|
# along with this program; if not, write to the Free Software
|
17
17
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
@@ -22,466 +22,329 @@ require 'i18n'
|
|
22
22
|
require 'yaml'
|
23
23
|
require 'rotp'
|
24
24
|
require 'mpw/item'
|
25
|
-
|
26
|
-
module MPW
|
27
|
-
class MPW
|
28
|
-
|
29
|
-
# Constructor
|
30
|
-
def initialize(key, wallet_file, gpg_pass=nil, gpg_exe=nil)
|
31
|
-
@key = key
|
32
|
-
@gpg_pass = gpg_pass
|
33
|
-
@gpg_exe = gpg_exe
|
34
|
-
@wallet_file = wallet_file
|
35
|
-
|
36
|
-
if not @gpg_exe.to_s.empty?
|
37
|
-
GPGME::Engine.set_info(GPGME::PROTOCOL_OpenPGP, @gpg_exe, "#{Dir.home}/.gnupg")
|
38
|
-
end
|
39
|
-
end
|
40
|
-
|
41
|
-
# Read mpw file
|
42
|
-
def read_data
|
43
|
-
@config = {}
|
44
|
-
@data = []
|
45
|
-
@keys = {}
|
46
|
-
@passwords = {}
|
47
|
-
@otp_keys = {}
|
48
|
-
|
49
|
-
data = nil
|
50
|
-
|
51
|
-
return if not File.exists?(@wallet_file)
|
52
|
-
|
53
|
-
Gem::Package::TarReader.new(File.open(@wallet_file)) do |tar|
|
54
|
-
tar.each do |f|
|
55
|
-
case f.full_name
|
56
|
-
when 'wallet/config.gpg'
|
57
|
-
@config = YAML.load(decrypt(f.read))
|
58
|
-
|
59
|
-
when 'wallet/meta.gpg'
|
60
|
-
data = decrypt(f.read)
|
61
|
-
|
62
|
-
when /^wallet\/keys\/(?<key>.+)\.pub$/
|
63
|
-
key = Regexp.last_match('key')
|
64
|
-
|
65
|
-
if GPGME::Key.find(:public, key).length == 0
|
66
|
-
GPGME::Key.import(f.read, armor: true)
|
67
|
-
end
|
68
|
-
|
69
|
-
@keys[key] = f.read
|
70
|
-
|
71
|
-
when /^wallet\/passwords\/(?<id>[a-zA-Z0-9]+)\.gpg$/
|
72
|
-
@passwords[Regexp.last_match('id')] = f.read
|
73
|
-
|
74
|
-
when /^wallet\/otp_keys\/(?<id>[a-zA-Z0-9]+)\.gpg$/
|
75
|
-
@otp_keys[Regexp.last_match('id')] = f.read
|
76
|
-
|
77
|
-
else
|
78
|
-
next
|
79
|
-
end
|
80
|
-
end
|
81
|
-
end
|
82
|
-
|
83
|
-
if not data.nil? and not data.empty?
|
84
|
-
YAML.load(data).each_value do |d|
|
85
|
-
@data.push(Item.new(id: d['id'],
|
86
|
-
group: d['group'],
|
87
|
-
host: d['host'],
|
88
|
-
protocol: d['protocol'],
|
89
|
-
user: d['user'],
|
90
|
-
port: d['port'],
|
91
|
-
otp: @otp_keys.has_key?(d['id']),
|
92
|
-
comment: d['comment'],
|
93
|
-
last_edit: d['last_edit'],
|
94
|
-
created: d['created'],
|
95
|
-
)
|
96
|
-
)
|
97
|
-
end
|
98
|
-
end
|
99
|
-
|
100
|
-
add_key(@key) if @keys[@key].nil?
|
101
|
-
rescue Exception => e
|
102
|
-
raise "#{I18n.t('error.mpw_file.read_data')}\n#{e}"
|
103
|
-
end
|
104
|
-
|
105
|
-
# Encrypt a file
|
106
|
-
def write_data
|
107
|
-
data = {}
|
108
|
-
tmp_file = "#{@wallet_file}.tmp"
|
109
|
-
|
110
|
-
@data.each do |item|
|
111
|
-
next if item.empty?
|
112
|
-
|
113
|
-
data.merge!(item.id => { 'id' => item.id,
|
114
|
-
'group' => item.group,
|
115
|
-
'host' => item.host,
|
116
|
-
'protocol' => item.protocol,
|
117
|
-
'user' => item.user,
|
118
|
-
'port' => item.port,
|
119
|
-
'comment' => item.comment,
|
120
|
-
'last_edit' => item.last_edit,
|
121
|
-
'created' => item.created,
|
122
|
-
}
|
123
|
-
)
|
124
|
-
end
|
125
|
-
|
126
|
-
@config['last_update'] = Time.now.to_i
|
127
|
-
|
128
|
-
Gem::Package::TarWriter.new(File.open(tmp_file, 'w+')) do |tar|
|
129
|
-
data_encrypt = encrypt(data.to_yaml)
|
130
|
-
tar.add_file_simple('wallet/meta.gpg', 0400, data_encrypt.length) do |io|
|
131
|
-
io.write(data_encrypt)
|
132
|
-
end
|
133
|
-
|
134
|
-
config = encrypt(@config.to_yaml)
|
135
|
-
tar.add_file_simple('wallet/config.gpg', 0400, config.length) do |io|
|
136
|
-
io.write(config)
|
137
|
-
end
|
138
|
-
|
139
|
-
@passwords.each do |id, password|
|
140
|
-
tar.add_file_simple("wallet/passwords/#{id}.gpg", 0400, password.length) do |io|
|
141
|
-
io.write(password)
|
142
|
-
end
|
143
|
-
end
|
144
|
-
|
145
|
-
@otp_keys.each do |id, key|
|
146
|
-
tar.add_file_simple("wallet/otp_keys/#{id}.gpg", 0400, key.length) do |io|
|
147
|
-
io.write(key)
|
148
|
-
end
|
149
|
-
end
|
150
|
-
|
151
|
-
@keys.each do |id, key|
|
152
|
-
tar.add_file_simple("wallet/keys/#{id}.pub", 0400, key.length) do |io|
|
153
|
-
io.write(key)
|
154
|
-
end
|
155
|
-
end
|
156
|
-
end
|
157
|
-
|
158
|
-
File.rename(tmp_file, @wallet_file)
|
159
|
-
rescue Exception => e
|
160
|
-
File.unlink(tmp_file) if File.exist?(tmp_file)
|
161
|
-
|
162
|
-
raise "#{I18n.t('error.mpw_file.write_data')}\n#{e}"
|
163
|
-
end
|
164
|
-
|
165
|
-
# Get a password
|
166
|
-
# args: id -> the item id
|
167
|
-
def get_password(id)
|
168
|
-
password = decrypt(@passwords[id])
|
169
|
-
|
170
|
-
if /^\$[a-zA-Z0-9]{4,9}::(?<password>.+)$/ =~ password
|
171
|
-
return Regexp.last_match('password')
|
172
|
-
else
|
173
|
-
return password
|
174
|
-
end
|
175
|
-
end
|
176
|
-
|
177
|
-
# Set a password
|
178
|
-
# args: id -> the item id
|
179
|
-
# password -> the new password
|
180
|
-
def set_password(id, password)
|
181
|
-
salt = MPW::password(length: Random.rand(4..9))
|
182
|
-
password = "$#{salt}::#{password}"
|
183
|
-
|
184
|
-
@passwords[id] = encrypt(password)
|
185
|
-
end
|
186
|
-
|
187
|
-
# Return the list of all gpg keys
|
188
|
-
# rtrn: an array with the gpg keys name
|
189
|
-
def list_keys
|
190
|
-
return @keys.keys
|
191
|
-
end
|
192
|
-
|
193
|
-
# Add a public key
|
194
|
-
# args: key -> new public key file or name
|
195
|
-
def add_key(key)
|
196
|
-
if File.exists?(key)
|
197
|
-
data = File.open(key).read
|
198
|
-
key_import = GPGME::Key.import(data, armor: true)
|
199
|
-
key = GPGME::Key.get(key_import.imports[0].fpr).uids[0].email
|
200
|
-
else
|
201
|
-
data = GPGME::Key.export(key, armor: true).read
|
202
|
-
end
|
203
|
-
|
204
|
-
if data.to_s.empty?
|
205
|
-
raise I18n.t('error.export_key')
|
206
|
-
end
|
207
|
-
|
208
|
-
@keys[key] = data
|
209
|
-
@passwords.each_key { |id| set_password(id, get_password(id)) }
|
210
|
-
@otp_keys.each_key { |id| set_otp_key(id, get_otp_key(id)) }
|
211
|
-
end
|
212
|
-
|
213
|
-
# Delete a public key
|
214
|
-
# args: key -> public key to delete
|
215
|
-
def delete_key(key)
|
216
|
-
@keys.delete(key)
|
217
|
-
@passwords.each_key { |id| set_password(id, get_password(id)) }
|
218
|
-
@otp_keys.each_key { |id| set_otp_key(id, get_otp_key(id)) }
|
219
|
-
end
|
220
|
-
|
221
|
-
# Set config
|
222
|
-
# args: config -> a hash with config options
|
223
|
-
def set_config(options={})
|
224
|
-
@config = {} if @config.nil?
|
225
|
-
|
226
|
-
@config['protocol'] = options[:protocol] if options.has_key?(:protocol)
|
227
|
-
@config['host'] = options[:host] if options.has_key?(:host)
|
228
|
-
@config['port'] = options[:port] if options.has_key?(:port)
|
229
|
-
@config['user'] = options[:user] if options.has_key?(:user)
|
230
|
-
@config['password'] = options[:password] if options.has_key?(:password)
|
231
|
-
@config['path'] = options[:path] if options.has_key?(:path)
|
232
|
-
@config['last_sync'] = @config['last_sync'].nil? ? 0 : @config['last_sync']
|
233
|
-
end
|
234
|
-
|
235
|
-
# Add a new item
|
236
|
-
# @args: item -> Object MPW::Item
|
237
|
-
def add(item)
|
238
|
-
if not item.instance_of?(Item)
|
239
|
-
raise I18n.t('error.bad_class')
|
240
|
-
elsif item.empty?
|
241
|
-
raise I18n.t('error.empty')
|
242
|
-
else
|
243
|
-
@data.push(item)
|
244
|
-
end
|
245
|
-
end
|
246
|
-
|
247
|
-
# Search in some csv data
|
248
|
-
# @args: options -> a hash with paramaters
|
249
|
-
# @rtrn: a list with the resultat of the search
|
250
|
-
def list(options={})
|
251
|
-
result = []
|
252
|
-
|
253
|
-
search = options[:pattern].to_s.downcase
|
254
|
-
group = options[:group].to_s.downcase
|
255
|
-
|
256
|
-
@data.each do |item|
|
257
|
-
next if item.empty?
|
258
|
-
next if not group.empty? and not group.eql?(item.group.to_s.downcase)
|
259
|
-
|
260
|
-
host = item.host.to_s.downcase
|
261
|
-
comment = item.comment.to_s.downcase
|
262
|
-
|
263
|
-
if not host =~ /^.*#{search}.*$/ and not comment =~ /^.*#{search}.*$/
|
264
|
-
next
|
265
|
-
end
|
266
|
-
|
267
|
-
result.push(item)
|
268
|
-
end
|
269
|
-
|
270
|
-
return result
|
271
|
-
end
|
272
|
-
|
273
|
-
# Search in some csv data
|
274
|
-
# @args: id -> the id item
|
275
|
-
# @rtrn: a row with the result of the search
|
276
|
-
def search_by_id(id)
|
277
|
-
@data.each do |item|
|
278
|
-
return item if item.id == id
|
279
|
-
end
|
280
|
-
|
281
|
-
return nil
|
282
|
-
end
|
283
|
-
|
284
|
-
# Get last sync
|
285
|
-
def get_last_sync
|
286
|
-
return @config['last_sync'].to_i
|
287
|
-
rescue
|
288
|
-
return 0
|
289
|
-
end
|
290
|
-
|
291
|
-
# Sync data with remote file
|
292
|
-
# @args: force -> force the sync
|
293
|
-
def sync(force=false)
|
294
|
-
return if @config.empty? or @config['protocol'].to_s.empty?
|
295
|
-
return if get_last_sync + 300 > Time.now.to_i and not force
|
296
|
-
|
297
|
-
tmp_file = "#{@wallet_file}.sync"
|
298
|
-
|
299
|
-
case @config['protocol']
|
300
|
-
when 'sftp', 'scp', 'ssh'
|
301
|
-
require "mpw/sync/ssh"
|
302
|
-
sync = SyncSSH.new(@config)
|
303
|
-
when 'ftp'
|
304
|
-
require 'mpw/sync/ftp'
|
305
|
-
sync = SyncFTP.new(@config)
|
306
|
-
else
|
307
|
-
raise I18n.t('error.sync.unknown_type')
|
308
|
-
end
|
309
|
-
|
310
|
-
sync.connect
|
311
|
-
sync.get(tmp_file)
|
312
|
-
|
313
|
-
remote = MPW.new(@key, tmp_file, @gpg_pass, @gpg_exe)
|
314
|
-
remote.read_data
|
315
|
-
|
316
|
-
File.unlink(tmp_file) if File.exist?(tmp_file)
|
317
|
-
|
318
|
-
return if remote.get_last_sync == @config['last_update']
|
319
|
-
|
320
|
-
if not remote.to_s.empty?
|
321
|
-
@data.each do |item|
|
322
|
-
next if item.empty?
|
323
|
-
|
324
|
-
update = false
|
325
|
-
|
326
|
-
remote.list.each do |r|
|
327
|
-
next if item.id != r.id
|
328
|
-
|
329
|
-
# Update item
|
330
|
-
if item.last_edit < r.last_edit
|
331
|
-
item.update(group: r.group,
|
332
|
-
host: r.host,
|
333
|
-
protocol: r.protocol,
|
334
|
-
user: r.user,
|
335
|
-
port: r.port,
|
336
|
-
comment: r.comment
|
337
|
-
)
|
338
|
-
set_password(item.id, remote.get_password(item.id))
|
339
|
-
end
|
340
|
-
|
341
|
-
r.delete
|
342
|
-
update = true
|
343
|
-
|
344
|
-
break
|
345
|
-
end
|
346
|
-
|
347
|
-
# Remove an old item
|
348
|
-
if not update and item.last_sync.to_i < get_last_sync and item.last_edit < get_last_sync
|
349
|
-
item.delete
|
350
|
-
end
|
351
|
-
end
|
352
|
-
end
|
353
|
-
|
354
|
-
# Add item
|
355
|
-
remote.list.each do |r|
|
356
|
-
next if r.last_edit <= get_last_sync
|
357
|
-
|
358
|
-
item = Item.new(id: r.id,
|
359
|
-
group: r.group,
|
360
|
-
host: r.host,
|
361
|
-
protocol: r.protocol,
|
362
|
-
user: r.user,
|
363
|
-
port: r.port,
|
364
|
-
comment: r.comment,
|
365
|
-
created: r.created,
|
366
|
-
last_edit: r.last_edit
|
367
|
-
)
|
368
|
-
|
369
|
-
set_password(item.id, remote.get_password(item.id))
|
370
|
-
add(item)
|
371
|
-
end
|
372
|
-
|
373
|
-
remote = nil
|
374
|
-
|
375
|
-
@data.each do |item|
|
376
|
-
item.set_last_sync
|
377
|
-
end
|
378
|
-
|
379
|
-
@config['last_sync'] = Time.now.to_i
|
380
|
-
|
381
|
-
write_data
|
382
|
-
sync.update(@wallet_file)
|
383
|
-
rescue Exception => e
|
384
|
-
File.unlink(tmp_file) if File.exist?(tmp_file)
|
385
|
-
|
386
|
-
raise "#{I18n.t('error.sync.general')}\n#{e}"
|
387
|
-
end
|
388
|
-
|
389
|
-
# Set an opt key
|
390
|
-
# args: id -> the item id
|
391
|
-
# key -> the new key
|
392
|
-
def set_otp_key(id, key)
|
393
|
-
if not key.to_s.empty?
|
394
|
-
@otp_keys[id] = encrypt(key.to_s)
|
395
|
-
end
|
396
|
-
end
|
397
|
-
|
398
|
-
# Get an opt key
|
399
|
-
# args: id -> the item id
|
400
|
-
# key -> the new key
|
401
|
-
def get_otp_key(id)
|
402
|
-
if @otp_keys.has_key?(id)
|
403
|
-
return decrypt(@otp_keys[id])
|
404
|
-
else
|
405
|
-
return nil
|
406
|
-
end
|
407
|
-
end
|
408
|
-
|
409
|
-
|
410
|
-
# Get an otp code
|
411
|
-
# @args: id -> the item id
|
412
|
-
# @rtrn: an otp code
|
413
|
-
def get_otp_code(id)
|
414
|
-
if not @otp_keys.has_key?(id)
|
415
|
-
return 0
|
416
|
-
else
|
417
|
-
return ROTP::TOTP.new(decrypt(@otp_keys[id])).now
|
418
|
-
end
|
419
|
-
end
|
420
|
-
|
421
|
-
# Get remaining time before expire otp code
|
422
|
-
# @rtrn: return time in seconde
|
423
|
-
def get_otp_remaining_time
|
424
|
-
return (Time.now.utc.to_i / 30 + 1) * 30 - Time.now.utc.to_i
|
425
|
-
end
|
426
|
-
|
427
|
-
# Generate a random password
|
428
|
-
# @args: options -> :length, :special, :alpha, :numeric
|
429
|
-
# @rtrn: a random string
|
430
|
-
def self.password(options={})
|
431
|
-
if not options.include?(:length) or options[:length].to_i <= 0
|
432
|
-
length = 8
|
433
|
-
elsif options[:length].to_i >= 32768
|
434
|
-
length = 32768
|
435
|
-
else
|
436
|
-
length = options[:length].to_i
|
437
|
-
end
|
438
|
-
|
439
|
-
chars = []
|
440
|
-
chars += [*('!'..'?')] - [*('0'..'9')] if options[:special]
|
441
|
-
chars += [*('A'..'Z'),*('a'..'z')] if options[:alpha]
|
442
|
-
chars += [*('0'..'9')] if options[:numeric]
|
443
|
-
chars = [*('A'..'Z'),*('a'..'z'),*('0'..'9')] if chars.empty?
|
444
|
-
|
445
|
-
result = ''
|
446
|
-
while length > 62 do
|
447
|
-
result << chars.sample(62).join
|
448
|
-
length -= 62
|
449
|
-
end
|
450
|
-
result << chars.sample(length).join
|
451
|
-
|
452
|
-
return result
|
453
|
-
end
|
454
|
-
|
455
|
-
# Decrypt a gpg file
|
456
|
-
# @args: data -> string to decrypt
|
457
|
-
private
|
458
|
-
def decrypt(data)
|
459
|
-
return nil if data.to_s.empty?
|
460
|
-
|
461
|
-
crypto = GPGME::Crypto.new(armor: true)
|
462
|
-
|
463
|
-
return crypto.decrypt(data, password: @gpg_pass).read.force_encoding('utf-8')
|
464
|
-
rescue Exception => e
|
465
|
-
raise "#{I18n.t('error.gpg_file.decrypt')}\n#{e}"
|
466
|
-
end
|
467
|
-
|
468
|
-
# Encrypt a file
|
469
|
-
# args: data -> string to encrypt
|
470
|
-
private
|
471
|
-
def encrypt(data)
|
472
|
-
recipients = []
|
473
|
-
crypto = GPGME::Crypto.new(armor: true, always_trust: true)
|
474
|
-
|
475
|
-
recipients.push(@key)
|
476
|
-
@keys.each_key do |key|
|
477
|
-
next if key == @key
|
478
|
-
recipients.push(key)
|
479
|
-
end
|
480
|
-
|
481
|
-
return crypto.encrypt(data, recipients: recipients).read
|
482
|
-
rescue Exception => e
|
483
|
-
raise "#{I18n.t('error.gpg_file.encrypt')}\n#{e}"
|
484
|
-
end
|
485
25
|
|
486
|
-
|
26
|
+
module MPW
|
27
|
+
class MPW
|
28
|
+
# Constructor
|
29
|
+
def initialize(key, wallet_file, gpg_pass = nil, gpg_exe = nil, pinmode = false)
|
30
|
+
@key = key
|
31
|
+
@gpg_pass = gpg_pass
|
32
|
+
@gpg_exe = gpg_exe
|
33
|
+
@wallet_file = wallet_file
|
34
|
+
@pinmode = pinmode
|
35
|
+
|
36
|
+
GPGME::Engine.set_info(GPGME::PROTOCOL_OpenPGP, @gpg_exe, "#{Dir.home}/.gnupg") unless @gpg_exe.to_s.empty?
|
37
|
+
end
|
38
|
+
|
39
|
+
# Read mpw file
|
40
|
+
def read_data
|
41
|
+
@data = []
|
42
|
+
@keys = {}
|
43
|
+
@passwords = {}
|
44
|
+
@otp_keys = {}
|
45
|
+
|
46
|
+
data = nil
|
47
|
+
|
48
|
+
return unless File.exist?(@wallet_file)
|
49
|
+
|
50
|
+
Gem::Package::TarReader.new(File.open(@wallet_file)) do |tar|
|
51
|
+
tar.each do |f|
|
52
|
+
case f.full_name
|
53
|
+
when 'wallet/meta.gpg'
|
54
|
+
data = decrypt(f.read)
|
55
|
+
|
56
|
+
when %r{^wallet/keys/(?<key>.+)\.pub$}
|
57
|
+
key = Regexp.last_match('key')
|
58
|
+
|
59
|
+
if GPGME::Key.find(:public, key).empty?
|
60
|
+
GPGME::Key.import(f.read, armor: true)
|
61
|
+
end
|
62
|
+
|
63
|
+
@keys[key] = f.read
|
64
|
+
|
65
|
+
when %r{^wallet/passwords/(?<id>[a-zA-Z0-9]+)\.gpg$}
|
66
|
+
@passwords[Regexp.last_match('id')] = f.read
|
67
|
+
|
68
|
+
when %r{^wallet/otp_keys/(?<id>[a-zA-Z0-9]+)\.gpg$}
|
69
|
+
@otp_keys[Regexp.last_match('id')] = f.read
|
70
|
+
|
71
|
+
else
|
72
|
+
next
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
unless data.to_s.empty?
|
78
|
+
YAML.safe_load(data).each_value do |d|
|
79
|
+
@data.push(
|
80
|
+
Item.new(
|
81
|
+
id: d['id'],
|
82
|
+
group: d['group'],
|
83
|
+
host: d['host'],
|
84
|
+
protocol: d['protocol'],
|
85
|
+
user: d['user'],
|
86
|
+
port: d['port'],
|
87
|
+
otp: @otp_keys.key?(d['id']),
|
88
|
+
comment: d['comment'],
|
89
|
+
last_edit: d['last_edit'],
|
90
|
+
created: d['created'],
|
91
|
+
)
|
92
|
+
)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
add_key(@key) unless @keys.key?(@key)
|
97
|
+
rescue => e
|
98
|
+
raise "#{I18n.t('error.mpw_file.read_data')}\n#{e}"
|
99
|
+
end
|
100
|
+
|
101
|
+
# Encrypt a file
|
102
|
+
def write_data
|
103
|
+
data = {}
|
104
|
+
tmp_file = "#{@wallet_file}.tmp"
|
105
|
+
|
106
|
+
@data.each do |item|
|
107
|
+
next if item.empty?
|
108
|
+
|
109
|
+
data.merge!(
|
110
|
+
item.id => {
|
111
|
+
'id' => item.id,
|
112
|
+
'group' => item.group,
|
113
|
+
'host' => item.host,
|
114
|
+
'protocol' => item.protocol,
|
115
|
+
'user' => item.user,
|
116
|
+
'port' => item.port,
|
117
|
+
'comment' => item.comment,
|
118
|
+
'last_edit' => item.last_edit,
|
119
|
+
'created' => item.created,
|
120
|
+
}
|
121
|
+
)
|
122
|
+
end
|
123
|
+
|
124
|
+
Gem::Package::TarWriter.new(File.open(tmp_file, 'w+')) do |tar|
|
125
|
+
data_encrypt = encrypt(data.to_yaml)
|
126
|
+
tar.add_file_simple('wallet/meta.gpg', 0400, data_encrypt.length) do |io|
|
127
|
+
io.write(data_encrypt)
|
128
|
+
end
|
129
|
+
|
130
|
+
@passwords.each do |id, password|
|
131
|
+
tar.add_file_simple("wallet/passwords/#{id}.gpg", 0400, password.length) do |io|
|
132
|
+
io.write(password)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
@otp_keys.each do |id, key|
|
137
|
+
tar.add_file_simple("wallet/otp_keys/#{id}.gpg", 0400, key.length) do |io|
|
138
|
+
io.write(key)
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
@keys.each do |id, key|
|
143
|
+
tar.add_file_simple("wallet/keys/#{id}.pub", 0400, key.length) do |io|
|
144
|
+
io.write(key)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
File.rename(tmp_file, @wallet_file)
|
150
|
+
rescue => e
|
151
|
+
File.unlink(tmp_file) if File.exist?(tmp_file)
|
152
|
+
|
153
|
+
raise "#{I18n.t('error.mpw_file.write_data')}\n#{e}"
|
154
|
+
end
|
155
|
+
|
156
|
+
# Get a password
|
157
|
+
# args: id -> the item id
|
158
|
+
def get_password(id)
|
159
|
+
password = decrypt(@passwords[id])
|
160
|
+
|
161
|
+
if /^\$[a-zA-Z0-9]{4,9}::(?<password>.+)$/ =~ password
|
162
|
+
Regexp.last_match('password')
|
163
|
+
else
|
164
|
+
password
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# Set a password
|
169
|
+
# args: id -> the item id
|
170
|
+
# password -> the new password
|
171
|
+
def set_password(id, password)
|
172
|
+
salt = MPW.password(length: Random.rand(4..9))
|
173
|
+
password = "$#{salt}::#{password}"
|
174
|
+
|
175
|
+
@passwords[id] = encrypt(password)
|
176
|
+
end
|
177
|
+
|
178
|
+
# Return the list of all gpg keys
|
179
|
+
# rtrn: an array with the gpg keys name
|
180
|
+
def list_keys
|
181
|
+
@keys.keys
|
182
|
+
end
|
183
|
+
|
184
|
+
# Add a public key
|
185
|
+
# args: key -> new public key file or name
|
186
|
+
def add_key(key)
|
187
|
+
if File.exist?(key)
|
188
|
+
data = File.open(key).read
|
189
|
+
key_import = GPGME::Key.import(data, armor: true)
|
190
|
+
key = GPGME::Key.get(key_import.imports[0].fpr).uids[0].email
|
191
|
+
else
|
192
|
+
data = GPGME::Key.export(key, armor: true).read
|
193
|
+
end
|
194
|
+
|
195
|
+
raise I18n.t('error.export_key') if data.to_s.empty?
|
196
|
+
|
197
|
+
@keys[key] = data
|
198
|
+
@passwords.each_key { |id| set_password(id, get_password(id)) }
|
199
|
+
@otp_keys.each_key { |id| set_otp_key(id, get_otp_key(id)) }
|
200
|
+
end
|
201
|
+
|
202
|
+
# Delete a public key
|
203
|
+
# args: key -> public key to delete
|
204
|
+
def delete_key(key)
|
205
|
+
@keys.delete(key)
|
206
|
+
@passwords.each_key { |id| set_password(id, get_password(id)) }
|
207
|
+
@otp_keys.each_key { |id| set_otp_key(id, get_otp_key(id)) }
|
208
|
+
end
|
209
|
+
|
210
|
+
# Add a new item
|
211
|
+
# @args: item -> Object MPW::Item
|
212
|
+
def add(item)
|
213
|
+
raise I18n.t('error.bad_class') unless item.instance_of?(Item)
|
214
|
+
raise I18n.t('error.empty') if item.empty?
|
215
|
+
|
216
|
+
@data.push(item)
|
217
|
+
end
|
218
|
+
|
219
|
+
# Search in some csv data
|
220
|
+
# @args: options -> a hash with paramaters
|
221
|
+
# @rtrn: a list with the resultat of the search
|
222
|
+
def list(**options)
|
223
|
+
result = []
|
224
|
+
|
225
|
+
search = options[:pattern].to_s.downcase
|
226
|
+
group = options[:group].to_s.downcase
|
227
|
+
|
228
|
+
@data.each do |item|
|
229
|
+
next if item.empty?
|
230
|
+
next unless group.empty? || group.eql?(item.group.to_s.downcase)
|
231
|
+
|
232
|
+
host = item.host.to_s.downcase
|
233
|
+
comment = item.comment.to_s.downcase
|
234
|
+
|
235
|
+
next unless host =~ /^.*#{search}.*$/ || comment =~ /^.*#{search}.*$/
|
236
|
+
|
237
|
+
result.push(item)
|
238
|
+
end
|
239
|
+
|
240
|
+
result
|
241
|
+
end
|
242
|
+
|
243
|
+
# Search in some csv data
|
244
|
+
# @args: id -> the id item
|
245
|
+
# @rtrn: a row with the result of the search
|
246
|
+
def search_by_id(id)
|
247
|
+
@data.each do |item|
|
248
|
+
return item if item.id == id
|
249
|
+
end
|
250
|
+
|
251
|
+
nil
|
252
|
+
end
|
253
|
+
|
254
|
+
# Set an opt key
|
255
|
+
# args: id -> the item id
|
256
|
+
# key -> the new key
|
257
|
+
def set_otp_key(id, key)
|
258
|
+
@otp_keys[id] = encrypt(key.to_s) unless key.to_s.empty?
|
259
|
+
end
|
260
|
+
|
261
|
+
# Get an opt key
|
262
|
+
# args: id -> the item id
|
263
|
+
# key -> the new key
|
264
|
+
def get_otp_key(id)
|
265
|
+
@otp_keys.key?(id) ? decrypt(@otp_keys[id]) : nil
|
266
|
+
end
|
267
|
+
|
268
|
+
# Get an otp code
|
269
|
+
# @args: id -> the item id
|
270
|
+
# @rtrn: an otp code
|
271
|
+
def get_otp_code(id)
|
272
|
+
@otp_keys.key?(id) ? 0 : ROTP::TOTP.new(decrypt(@otp_keys[id])).now
|
273
|
+
end
|
274
|
+
|
275
|
+
# Get remaining time before expire otp code
|
276
|
+
# @rtrn: return time in seconde
|
277
|
+
def get_otp_remaining_time
|
278
|
+
(Time.now.utc.to_i / 30 + 1) * 30 - Time.now.utc.to_i
|
279
|
+
end
|
280
|
+
|
281
|
+
# Generate a random password
|
282
|
+
# @args: options -> :length, :special, :alpha, :numeric
|
283
|
+
# @rtrn: a random string
|
284
|
+
def self.password(**options)
|
285
|
+
length =
|
286
|
+
if !options.include?(:length) || options[:length].to_i <= 0
|
287
|
+
8
|
288
|
+
elsif options[:length].to_i >= 32_768
|
289
|
+
32_768
|
290
|
+
else
|
291
|
+
options[:length].to_i
|
292
|
+
end
|
293
|
+
|
294
|
+
chars = []
|
295
|
+
chars += [*('!'..'?')] - [*('0'..'9')] if options[:special]
|
296
|
+
chars += [*('A'..'Z'), *('a'..'z')] if options[:alpha]
|
297
|
+
chars += [*('0'..'9')] if options[:numeric]
|
298
|
+
chars = [*('A'..'Z'), *('a'..'z'), *('0'..'9')] if chars.empty?
|
299
|
+
|
300
|
+
result = ''
|
301
|
+
while length > 62
|
302
|
+
result << chars.sample(62).join
|
303
|
+
length -= 62
|
304
|
+
end
|
305
|
+
result << chars.sample(length).join
|
306
|
+
|
307
|
+
result
|
308
|
+
end
|
309
|
+
|
310
|
+
private
|
311
|
+
|
312
|
+
# Decrypt a gpg file
|
313
|
+
# @args: data -> string to decrypt
|
314
|
+
def decrypt(data)
|
315
|
+
return nil if data.to_s.empty?
|
316
|
+
|
317
|
+
password =
|
318
|
+
if /^(1\.[0-9.]+|2\.0)(\.[0-9]+)?/ =~ GPGME::Engine.info.first.version || @pinmode
|
319
|
+
{ password: @gpg_pass }
|
320
|
+
else
|
321
|
+
{ password: @gpg_pass,
|
322
|
+
pinentry_mode: GPGME::PINENTRY_MODE_LOOPBACK }
|
323
|
+
end
|
324
|
+
|
325
|
+
crypto = GPGME::Crypto.new(armor: true)
|
326
|
+
crypto
|
327
|
+
.decrypt(data, password)
|
328
|
+
.read.force_encoding('utf-8')
|
329
|
+
rescue => e
|
330
|
+
raise "#{I18n.t('error.gpg_file.decrypt')}\n#{e}"
|
331
|
+
end
|
332
|
+
|
333
|
+
# Encrypt a file
|
334
|
+
# args: data -> string to encrypt
|
335
|
+
def encrypt(data)
|
336
|
+
recipients = []
|
337
|
+
crypto = GPGME::Crypto.new(armor: true, always_trust: true)
|
338
|
+
|
339
|
+
recipients.push(@key)
|
340
|
+
@keys.each_key do |key|
|
341
|
+
next if key == @key
|
342
|
+
recipients.push(key)
|
343
|
+
end
|
344
|
+
|
345
|
+
crypto.encrypt(data, recipients: recipients).read
|
346
|
+
rescue => e
|
347
|
+
raise "#{I18n.t('error.gpg_file.encrypt')}\n#{e}"
|
348
|
+
end
|
349
|
+
end
|
487
350
|
end
|