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/README.md +18 -14
- data/features/add.feature +14 -2
- data/features/create.feature +23 -0
- data/features/in-out.feature +54 -0
- data/features/master.feature +1 -0
- data/features/misc.feature +14 -0
- data/features/namespaces.feature +6 -4
- data/features/remove.feature +1 -0
- data/features/resave.feature +37 -0
- data/features/show.feature +36 -3
- data/features/step_definitions/pws_steps.rb +42 -2
- data/features/support/env.rb +3 -2
- data/features/update.feature +60 -0
- data/lib/pws.rb +109 -80
- data/lib/pws/encryptor.rb +28 -24
- data/lib/pws/format.rb +103 -0
- data/lib/pws/format/0.9.rb +44 -0
- data/lib/pws/format/1.0.rb +183 -0
- data/lib/pws/runner.rb +21 -2
- data/lib/pws/version.rb +1 -1
- data/pws.gemspec +13 -7
- metadata +96 -9
- data/features/access.feature +0 -13
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
|
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
|
-
|
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
|
-
|
46
|
-
|
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
|
-
|
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] =
|
62
|
-
|
63
|
-
|
64
|
-
|
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
|
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
|
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
|
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
|
-
|
176
|
+
unless @password = password
|
152
177
|
new_password = ask_for_password(%[please enter the new master password], :yellow, :bold)
|
153
|
-
password
|
154
|
-
if new_password != password
|
155
|
-
|
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
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
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
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
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
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
@
|
205
|
-
|
206
|
-
|
207
|
-
|
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
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
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
|
-
|
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
|
-
|
269
|
+
password
|
241
270
|
end
|
242
271
|
end
|
243
272
|
|
244
|
-
# Command line action in
|
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(
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
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
|
19
|
-
|
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
|
-
|
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(
|
33
|
-
c = OpenSSL::Cipher.new
|
34
|
-
c.send
|
35
|
-
c.key =
|
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(
|
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
|