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