mpw 2.0.3 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
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