pws 0.9.2 → 1.0.0.pre.1

Sign up to get free protection for your applications and to get access to all the features.
data/lib/pws.rb CHANGED
@@ -1,5 +1,7 @@
1
+ # encoding: ascii
1
2
  require_relative 'pws/version'
2
3
  require_relative 'pws/encryptor'
4
+ require_relative 'pws/format'
3
5
 
4
6
  require 'fileutils'
5
7
  require 'clipboard'
@@ -9,8 +11,9 @@ require 'paint/pa'
9
11
 
10
12
  class PWS
11
13
  class NoAccess < StandardError; end
14
+ class NoLegacyAccess < NoAccess; end
12
15
 
13
- attr_reader :filename, :options
16
+ attr_reader :filename
14
17
 
15
18
  # Creates a new password safe. Takes the path to the password file, by default: ~/.pws
16
19
  # Second parameter allows namespaces that get appended to the file name (uses another safe)
@@ -20,16 +23,7 @@ class PWS
20
23
  @filename = File.expand_path(@options[:filename])
21
24
  @filename << '-' << @options[:namespace] if @options[:namespace]
22
25
 
23
- access_safe(options[:password])
24
- read_safe
25
- end
26
-
27
- def collect_options(options = {})
28
- @options = options
29
- @options[:filename] ||= ENV["PWS"] || '~/.pws'
30
- @options[:seconds] ||= ENV['PWS_SECONDS'] || 10
31
- @options[:length] ||= ENV['PWS_LENGTH'] || 64
32
- @options[:charpool] ||= ENV['PWS_CHARPOOL'] || (33..126).map(&:chr).join
26
+ read_safe(options[:password])
33
27
  end
34
28
 
35
29
  # Shows a password entry list
@@ -42,13 +36,24 @@ class PWS
42
36
  else
43
37
  keys = @data.keys
44
38
  end
45
- puts Paint["Entries", :underline] + %[ in ] + @filename
46
- puts keys.sort.map{ |key| %[- #{key}\n] }.join
39
+
40
+ if keys.empty?
41
+ pa %[No passwords found for pattern /#{pattern}/], :red
42
+ else
43
+ puts Paint["Entries", :underline] + %[ in ] + @filename
44
+ longest_key_size = keys.max_by(&:size).size
45
+ puts keys.sort.map{ |key|
46
+ "- #{
47
+ Paint[key, :bold]
48
+ } #{
49
+ Time.at(@data[key][:timestamp].to_i).strftime('%y-%m-%d') if @data[key][:timestamp].to_i != 0
50
+ }\n"
51
+ }.join
52
+ end
47
53
  end
48
54
  return true
49
55
  rescue RegexpError
50
- pa %[Invalid regex given], :red
51
- return false
56
+ raise ArgumentError, %[Invalid regex given]
52
57
  end
53
58
  aliases_for :show, :ls, :list, :status
54
59
 
@@ -58,25 +63,45 @@ class PWS
58
63
  pa %[There is already a password stored for #{key}. You need to remove it before creating a new one!], :red
59
64
  return false
60
65
  else
61
- @data[key] = password || ask_for_password(%[please enter a password for #{key}], :yellow)
62
- if @data[key].empty?
63
- pa %[Cannot add an empty password!], :red
64
- return false
66
+ @data[key] = {}
67
+ @data[key][:password] = password || ask_for_password(%[please enter a password for #{key}], :yellow)
68
+ if @data[key][:password].empty?
69
+ raise ArgumentError, %[Cannot set an empty password!]
65
70
  else
71
+ @data[key][:timestamp] = Time.now.to_i
66
72
  write_safe
67
73
  pa %[The password for #{key} has been added], :green
68
74
  return true
69
75
  end
70
76
  end
71
77
  end
72
- aliases_for :add, :set, :store, :create, :[]=
78
+ aliases_for :add, :set, :store, :create
79
+
80
+ # Updates a password entry, params: name, password (optional, opens prompt if not given)
81
+ def update(key, password = nil)
82
+ if !@data[key]
83
+ pa %[There is no password stored for #{key}, so you cannot update it!], :red
84
+ return false
85
+ else
86
+ @data[key][:password] = password || ask_for_password(%[please enter a new password for #{key}], :yellow)
87
+ if @data[key][:password].empty?
88
+ raise ArgumentError, %[Cannot set an empty password!]
89
+ else
90
+ @data[key][:timestamp] = Time.now.to_i
91
+ write_safe
92
+ pa %[The password for #{key} has been updated], :green
93
+ return true
94
+ end
95
+ end
96
+ end
97
+ aliases_for :update, :change
73
98
 
74
99
  # Gets the password entry and copies it to the clipboard. The second parameter is the time in seconds it stays there
75
100
  def get(key, seconds = @options[:seconds])
76
- if pw_plaintext = @data[key]
101
+ if password = @data[key] && @data[key][:password]
77
102
  if seconds && seconds.to_i > 0
78
103
  original_clipboard_content = Clipboard.paste
79
- Clipboard.copy pw_plaintext
104
+ Clipboard.copy password
80
105
  pa %[The password for #{key} is now available in your clipboard for #{seconds.to_i} second#{?s if seconds.to_i > 1}], :green
81
106
  begin
82
107
  sleep seconds.to_i
@@ -87,7 +112,7 @@ class PWS
87
112
  Clipboard.copy original_clipboard_content
88
113
  return true
89
114
  else
90
- Clipboard.copy pw_plaintext
115
+ Clipboard.copy password
91
116
  pa %[The password for #{key} has been copied to your clipboard], :green
92
117
  return true
93
118
  end
@@ -148,20 +173,25 @@ class PWS
148
173
 
149
174
  # Changes the master password
150
175
  def master(password = nil)
151
- if !password
176
+ unless @password = password
152
177
  new_password = ask_for_password(%[please enter the new master password], :yellow, :bold)
153
- password = ask_for_password(%[please enter the new master password, again], :yellow, :bold)
154
- if new_password != password
155
- pa %[The passwords don't match!], :red
156
- return false
178
+ @password = ask_for_password(%[please enter the new master password, again], :yellow, :bold)
179
+ if new_password != @password
180
+ raise ArgumentError, %[The passwords don't match!]
157
181
  end
158
182
  end
159
- @hash = Encryptor.hash(password)
160
183
  write_safe
161
184
  pa %[The master password has been changed], :green
162
185
  return true
163
186
  end
164
187
 
188
+ # Just writes the safe, useful for converting with --in and --out
189
+ def resave
190
+ write_safe
191
+ pa %[The password file has been resaved], :green
192
+ return true
193
+ end
194
+
165
195
  # Prevents accidental displaying, e.g. in irb
166
196
  def to_s
167
197
  %[#<password safe>]
@@ -169,78 +199,77 @@ class PWS
169
199
  alias_for :to_s, :inspect
170
200
 
171
201
  private
202
+
203
+ def collect_options(options = {})
204
+ @options = options
205
+ @options[:filename] ||= ENV["PWS"] || '~/.pws'
206
+ @options[:seconds] ||= ENV['PWS_SECONDS'] || 10
207
+ @options[:length] ||= ENV['PWS_LENGTH'] || 64
208
+ @options[:charpool] ||= ENV['PWS_CHARPOOL'] || (33..126).map(&:chr).join
209
+ @options[:in] ||= ENV['PWS_FORMAT'] || VERSION
210
+ @options[:out] ||= ENV['PWS_FORMAT'] || VERSION
211
+ end
172
212
 
213
+ # Checks if the file is accessible or create a new one
173
214
  # Tries to load and decrypt the password safe from the pwfile
174
- def read_safe
175
- pwdata_raw = File.read(@filename)
176
- pwdata_encrypted = pwdata_raw.force_encoding("ascii")
177
- pwdata_dump = Encryptor.decrypt(pwdata_encrypted, @hash)
178
- pwdata_with_redundancy = Marshal.load(pwdata_dump)
179
- @data = remove_redundancy(pwdata_with_redundancy)
215
+ def read_safe(password = nil)
216
+ if File.file?(@filename)
217
+ print %[Access password safe at #@filename | ]
218
+ @password = password || ask_for_password(%[master password])
219
+ encrypted_data = File.read(@filename)
220
+
221
+ @data = Format.read(
222
+ encrypted_data,
223
+ password: @password,
224
+ format: @options[:in],
225
+ )
226
+ else
227
+ create_safe(password)
228
+ end
229
+
180
230
  pa %[ACCESS GRANTED], :green
181
- rescue
182
- fail NoAccess, %[Could not load and decrypt the password safe!]
183
231
  end
184
232
 
233
+
185
234
  # Tries to encrypt and save the password safe into the pwfile
186
- def write_safe(new_safe = false)
187
- pwdata_with_redundancy = add_redundancy(@data || {})
188
- pwdata_dump = Marshal.dump(pwdata_with_redundancy)
189
- pwdata_encrypted = Encryptor.encrypt(pwdata_dump, @hash)
190
- if new_safe
191
- FileUtils.mkdir_p(File.dirname(@filename))
192
- FileUtils.touch(@filename)
193
- File.chmod(0600, @filename)
194
- end
195
- File.open(@filename, 'w'){ |f| f.write(pwdata_encrypted) }
196
- rescue
197
- fail NoAccess, %[Could not encrypt and save the password safe!]
235
+ def write_safe
236
+ encrypted_data = Format.write(
237
+ @data,
238
+ password: @password,
239
+ format: @options[:out],
240
+ iterations: @options[:iterations], # TODO
241
+ )
242
+ File.open(@filename, 'w'){ |f| f.write(encrypted_data) }
198
243
  end
199
244
 
200
- # Checks if the file is accessible or create a new one
201
- def access_safe(password = nil)
202
- if !File.file? @filename
203
- pa %[No password safe detected, creating one at #@filename], :blue, :bold
204
- @hash = Encryptor.hash password || ask_for_password(%[please enter a new master password], :yellow, :bold)
205
- write_safe(true)
206
- else
207
- print %[Access password safe at #@filename | ]
208
- @hash = Encryptor.hash password || ask_for_password(%[master password])
245
+ def create_safe(password = nil)
246
+ pa %[No password safe detected, creating one at #@filename], :blue, :bold
247
+ unless @password = password
248
+ new_password = ask_for_password(%[please enter the new master password], :yellow, :bold)
249
+ @password = ask_for_password(%[please enter the new master password, again], :yellow, :bold)
250
+ if new_password != @password
251
+ raise ArgumentError, %[The passwords don't match!]
252
+ end
209
253
  end
210
- end
211
-
212
- # Adds some redundancy (to conceal how much you have stored)
213
- def add_redundancy(pw_data)
214
- entries = 8000 + SecureRandom.random_number(4000)
215
- position = SecureRandom.random_number(entries)
216
-
217
- ret = entries.times.map{ # or whatever... just create noise ;)
218
- { SecureRandom.uuid.chars.to_a.shuffle.join => SecureRandom.uuid.chars.to_a.shuffle.join }
219
- }
220
- ret[position] = pw_data
221
- ret << position
222
254
 
223
- ret
224
- end
225
-
226
- # And remove it
227
- def remove_redundancy(pw_data)
228
- position = pw_data[-1]
229
- pw_data[position]
255
+ FileUtils.mkdir_p(File.dirname(@filename))
256
+ @data = {}
257
+ write_safe
258
+ File.chmod(0600, @filename)
230
259
  end
231
260
 
232
261
  # Prompts the user for a password
233
262
  def ask_for_password(prompt = 'new password', *colors)
234
263
  print Paint["#{prompt}:".capitalize, *colors] + " "
235
264
  system 'stty -echo' if $stdin.tty? # no more terminal output
236
- pw_plaintext = ($stdin.gets||'').chop # gets without $stdin would mistakenly read_safe from ARGV
265
+ password = ($stdin.gets||'').chop # gets without $stdin would mistakenly read_safe from ARGV
237
266
  system 'stty echo' if $stdin.tty? # restore terminal output
238
267
  puts "\e[999D\e[K\e[1A" if $stdin.tty? # re-use prompt line in terminal
239
268
 
240
- pw_plaintext
269
+ password
241
270
  end
242
271
  end
243
272
 
244
- # Command line action in bin/pws
273
+ # Command line action in pws/runner.rb
245
274
 
246
275
  # J-_-L
data/lib/pws/encryptor.rb CHANGED
@@ -1,40 +1,44 @@
1
1
  require 'openssl'
2
2
 
3
+ # This encryptor class wraps around openssl to simplify
4
+ # en/decryption using AES 256 CBC
5
+ # Please note: Failed en/decryptions will raise errors
3
6
  module PWS::Encryptor
4
7
  class << self
5
8
  CIPHER = 'aes-256-cbc'
6
-
7
- def decrypt( iv_and_data, pwhash )
8
- iv, data = iv_and_data[0,16], iv_and_data[16..-1]
9
- crypt :decrypt, data, pwhash, iv
10
- end
11
-
12
- def encrypt( data, pwhash )
13
- iv = random_iv
14
- encrypted_data = crypt :encrypt, data, pwhash, iv
15
- iv + encrypted_data
9
+
10
+ def decrypt(encrypted_data, options = {})
11
+ crypt(
12
+ :decrypt,
13
+ encrypted_data,
14
+ options[:key],
15
+ options[:iv],
16
+ )
16
17
  end
17
-
18
- def hash( plaintext )
19
- OpenSSL::Digest::SHA512.new( plaintext ).digest
18
+
19
+ def encrypt(unecrypted_data, options = {})
20
+ crypt(
21
+ :encrypt,
22
+ unecrypted_data,
23
+ options[:key],
24
+ options[:iv],
25
+ )
20
26
  end
21
-
22
- # you need a random iv for cbc mode. It is prepended to the encrypted text.
27
+
23
28
  def random_iv
24
- a = OpenSSL::Cipher.new CIPHER
25
- a.random_iv
29
+ OpenSSL::Cipher.new(CIPHER).random_iv
26
30
  end
27
-
31
+
28
32
  private
29
-
33
+
30
34
  # Encrypts or decrypts the data with the password hash as key
31
35
  # NOTE: encryption exceptions do not get caught here!
32
- def crypt( decrypt_or_encrypt, data, pwhash, iv )
33
- c = OpenSSL::Cipher.new CIPHER
34
- c.send decrypt_or_encrypt.to_sym
35
- c.key = pwhash
36
+ def crypt(decrypt_or_encrypt, data, key, iv)
37
+ c = OpenSSL::Cipher.new(CIPHER)
38
+ c.send(decrypt_or_encrypt.to_sym)
39
+ c.key = key
36
40
  c.iv = iv
37
- c.update( data ) << c.final
41
+ c.update(data) << c.final
38
42
  end
39
43
  end
40
44
  end
data/lib/pws/format.rb ADDED
@@ -0,0 +1,103 @@
1
+ # encoding: ascii
2
+ require_relative '../pws'
3
+
4
+ class PWS
5
+ # The purpose of this module is redirecting to the proper format version module
6
+ # It also reads and writes the generic header (identifier + pws version)
7
+ # Format module can be specified by two integers, support for symbols may be
8
+ # added sometime to support special formats
9
+ # See for example: pws/format/1.0
10
+ module Format
11
+ IN = [[0,9], [1,0]]
12
+ OUT = [[1,0]]
13
+
14
+ class << self
15
+ def read(saved_data, options = {})
16
+ raise ArgumentError, 'No password file given' unless saved_data
17
+ format = normalize_format(options.delete(:format))
18
+
19
+ if format && !IN.include?(format)
20
+ raise ArgumentError, "Input format <#{format.join('.')}> is not supported"
21
+ end
22
+
23
+ if format == [0,9]
24
+ encrypted_data = saved_data
25
+ else
26
+ raise(PWS::NoAccess, 'Password file not valid') if \
27
+ saved_data.size <= 11
28
+ identifier, *file_format, encrypted_data =
29
+ saved_data.unpack("a8 C2 x a*")
30
+ raise(PWS::NoLegacyAccess, 'Password file not valid') unless \
31
+ identifier == '12345678'
32
+ format ||= normalize_format(file_format) # --in option wins againts read file_format
33
+
34
+ if !IN.include?(format)
35
+ raise PWS::NoAccess, "Input format <#{format.join('.')}> is not supported"
36
+ end
37
+ end
38
+
39
+
40
+ self[format].read(encrypted_data, options)
41
+ end
42
+
43
+ def write(application_data, options)
44
+ format = normalize_format(options.delete(:format) || PWS::VERSION)
45
+
46
+ raise ArgumentError, "Output format <#{format.join('.')}> is not supported" \
47
+ unless OUT.include?(format)
48
+
49
+ [
50
+ '12345678',
51
+ *format,
52
+ self[format].write(application_data, options),
53
+ ].pack('a8 C2 x a*')
54
+ end
55
+
56
+ # Returns the proper file format module for a given format identifier
57
+ # @return Module
58
+ def [](format)
59
+ error_message = 'Can only find format modules for symbols or arrays with exactly two integers'
60
+ case format
61
+ when Array
62
+ raise ArgumentError, error_message unless \
63
+ format.size == 2 && format[0].is_a?(Integer) && format[1].is_a?(Integer)
64
+ require_relative "format/#{ format.join('.') }"
65
+ module_name = "V#{ format.join('_') }"
66
+ return const_get(module_name)
67
+ when Symbol
68
+ require_relative "format/#{ format.to_s.gsub(/[^a-z_]/,'') }"
69
+ return const_get(format.capitalize)
70
+ end
71
+
72
+ raise ArgumentError, error_message
73
+ end
74
+
75
+ # Converts various version formats into an array of two integers
76
+ # Symbols won't be changed
77
+ def normalize_format(raw_format)
78
+ case raw_format
79
+ when Symbol
80
+ return raw_format
81
+ when Array
82
+ format = raw_format[0,2]
83
+ when String, Float, Integer
84
+ format = raw_format.to_s.split('.')[0,2]
85
+ when nil
86
+ return nil
87
+ end
88
+
89
+ if !format || !format.is_a?(Array) || !format[0]
90
+ raise ArgumentError, 'Invalid format given'
91
+ else
92
+ format.map!(&:to_i)
93
+ format[1] ||= 0
94
+ end
95
+
96
+ format
97
+ end
98
+
99
+ end#self
100
+ end#Format
101
+ end#PWS
102
+
103
+ # J-_-L