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