pws 0.9.2 → 1.0.0.pre.1

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/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