pwl 0.0.2 → 0.0.3
Sign up to get free protection for your applications and to get access to all the features.
- data/Gemfile +3 -1
- data/Gemfile.lock +19 -5
- data/VERSION +1 -1
- data/bin/pwl +47 -228
- data/lib/pwl.rb +12 -0
- data/lib/pwl/commands/add.rb +36 -0
- data/lib/pwl/commands/base.rb +98 -0
- data/lib/pwl/commands/delete.rb +18 -0
- data/lib/pwl/commands/export.rb +22 -0
- data/lib/pwl/commands/get.rb +26 -0
- data/lib/pwl/commands/init.rb +35 -0
- data/lib/pwl/commands/list.rb +38 -0
- data/lib/pwl/commands/passwd.rb +42 -0
- data/lib/pwl/commands/stats.rb +59 -0
- data/lib/pwl/entry.rb +31 -0
- data/lib/pwl/entry_mapper.rb +30 -0
- data/lib/pwl/locker.rb +18 -10
- data/lib/pwl/presenter/json.rb +6 -10
- data/lib/pwl/presenter/yaml.rb +8 -4
- data/pwl.gemspec +24 -5
- data/templates/export.html.erb +5 -5
- data/test/acceptance/test_export.rb +6 -3
- data/test/acceptance/test_export_json.rb +9 -6
- data/test/acceptance/test_export_yaml.rb +8 -5
- data/test/acceptance/test_init.rb +24 -4
- data/test/acceptance/test_list.rb +1 -1
- data/test/fixtures/test_all.html +3 -3
- data/test/fixtures/test_all.json +9 -6
- data/test/fixtures/test_all.yaml +6 -3
- data/test/helper.rb +4 -2
- data/test/unit/test_entry.rb +67 -0
- data/test/unit/test_entry_mapper.rb +33 -0
- data/test/unit/test_store_crud.rb +25 -11
- data/test/unit/test_store_password_policy.rb +1 -1
- data/test/unit/test_store_security.rb +2 -1
- metadata +51 -6
@@ -0,0 +1,98 @@
|
|
1
|
+
module Pwl
|
2
|
+
module Commands
|
3
|
+
EXIT_CODES = {
|
4
|
+
:success => Message.new('Success.'),
|
5
|
+
:aborted => Message.new('Aborted by user.', 1),
|
6
|
+
:passwords_dont_match => ErrorMessage.new('Passwords do not match.', 2),
|
7
|
+
:no_value_found => Message.new('No value found for <%= name %>.', 3, :name => 'NAME'),
|
8
|
+
:file_exists => ErrorMessage.new('There already exists a locker at <%= file %>. Use --force to overwrite it or --file to specify a different locker.', 4, :file => 'FILE'),
|
9
|
+
:file_not_found => ErrorMessage.new('Locker file <%= file %> could not be found.', 5, :file => 'FILE'),
|
10
|
+
:name_blank => ErrorMessage.new('Name may not be blank.', 6),
|
11
|
+
:value_blank => ErrorMessage.new('Value may not be blank.', 7),
|
12
|
+
:list_empty => Message.new('List is empty.', 8),
|
13
|
+
:list_empty_filter => Message.new('No names found that match filter <%= filter %>.', 9, :filter => 'FILTER'),
|
14
|
+
:validation_new_failed => ErrorMessage.new('<%= message %>.', 10, :message => 'Validation of new master password failed'),
|
15
|
+
:unknown_format => ErrorMessage.new('<%= format %> is not a known format.', 11, :format => 'FORMAT'),
|
16
|
+
:inaccessible_field => ErrorMessage.new("Field '<%= field %>' is not accessible.", 12, :field => 'FIELD'),
|
17
|
+
:is_dir => ErrorMessage.new('File expected, but <%= file %> is a directory. Specify a regular file for the locker.', 13, :file => 'FILE'),
|
18
|
+
}
|
19
|
+
|
20
|
+
class InacessibleFieldError < StandardError
|
21
|
+
def initialize(field)
|
22
|
+
super("The field #{field} is not accessible")
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
class Base
|
27
|
+
class << self
|
28
|
+
def exit_codes_help
|
29
|
+
EXIT_CODES.values.sort{|l,r| l.exit_code <=> r.exit_code}.collect{|m| " #{m.exit_code.to_s.rjust(EXIT_CODES.size.to_s.size)}: #{m.to_s}"}.join("\n")
|
30
|
+
end
|
31
|
+
|
32
|
+
def default_locker_file
|
33
|
+
File.expand_path("~/.#{program(:name)}.pstore")
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
protected
|
38
|
+
|
39
|
+
def locker_file(options, init = false)
|
40
|
+
result = options.file || self.class.default_locker_file
|
41
|
+
|
42
|
+
if File.exists?(result) || init
|
43
|
+
result
|
44
|
+
else
|
45
|
+
exit_with(:file_not_found, options.verbose, :file => result)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def open_locker(options, master_password = nil)
|
50
|
+
# TODO Use DRb at options.url if not nil
|
51
|
+
locker_file = locker_file(options)
|
52
|
+
msg "Attempting to open locker at #{locker_file}" if options.verbose
|
53
|
+
|
54
|
+
Locker.open(locker_file, master_password || get_password("Enter the master password for #{program(:name)}:", options.gui))
|
55
|
+
end
|
56
|
+
|
57
|
+
def new_locker(options, master_password)
|
58
|
+
# Remote init not allowed. Or maybe it should be?
|
59
|
+
Locker.new(locker_file(options, true), master_password, {:force => options.force})
|
60
|
+
end
|
61
|
+
|
62
|
+
def msg(str)
|
63
|
+
STDERR.puts("#{program(:name)}: #{str}")
|
64
|
+
end
|
65
|
+
|
66
|
+
def exit_with(error_code, verbose, msg_args = {})
|
67
|
+
msg = EXIT_CODES[error_code]
|
68
|
+
raise "No message defined for error #{error_code}" if !msg
|
69
|
+
|
70
|
+
if msg.error? || verbose # always print errors; messages only when verbose
|
71
|
+
msg msg.to_s(msg_args)
|
72
|
+
end
|
73
|
+
|
74
|
+
exit(msg.exit_code)
|
75
|
+
end
|
76
|
+
|
77
|
+
def get_password(prompt, gui = false)
|
78
|
+
(gui ? Pwl::Dialog::Password.new(program(:name), prompt) : Pwl::Dialog::ConsolePasswordDialog.new(prompt)).get_input
|
79
|
+
end
|
80
|
+
|
81
|
+
def get_text(prompt, gui = false)
|
82
|
+
(gui ? Pwl::Dialog::Text.new(program(:name), prompt) : Pwl::Dialog::ConsoleTextDialog.new(prompt)).get_input
|
83
|
+
end
|
84
|
+
|
85
|
+
def validate!(pwd)
|
86
|
+
Pwl::Locker.password_policy.validate!(pwd)
|
87
|
+
end
|
88
|
+
|
89
|
+
#
|
90
|
+
# Returns the value of the passed attribute name if it is allowed to be retrieved from a locker entry
|
91
|
+
#
|
92
|
+
def attr!(entry, field)
|
93
|
+
raise InacessibleFieldError.new(field) unless entry.instance_variable_defined?("@#{field}".to_sym)
|
94
|
+
entry.send(field)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Pwl
|
2
|
+
module Commands
|
3
|
+
class Delete < Base
|
4
|
+
def call(args, options)
|
5
|
+
exit_with(:name_blank, options.verbose) if 0 == args.size || args[0].blank?
|
6
|
+
|
7
|
+
begin
|
8
|
+
locker = open_locker(options)
|
9
|
+
rescue Pwl::Dialog::Cancelled
|
10
|
+
exit_with(:aborted, options.verbose)
|
11
|
+
end
|
12
|
+
|
13
|
+
locker.delete(args[0])
|
14
|
+
msg "Successfully deleted the value under #{args[0]}." if options.verbose
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Pwl
|
2
|
+
module Commands
|
3
|
+
class Export < Base
|
4
|
+
DEFAULT_EXPORT_TEMPLATE = File.join(File.dirname(__FILE__), *%w[.. .. templates export.html.erb])
|
5
|
+
|
6
|
+
def call(args, options)
|
7
|
+
options.default :format => 'html'
|
8
|
+
|
9
|
+
# TODO See Stats for slightly changed approach using a method
|
10
|
+
presenter = {:html => Presenter::Html, :json => Presenter::Json, :yaml => Presenter::Yaml}[options.format.to_sym]
|
11
|
+
exit_with(:unknown_export_format, options.verbose, :format => options.format) if presenter.nil?
|
12
|
+
|
13
|
+
begin
|
14
|
+
locker = open_locker(options)
|
15
|
+
puts presenter.new(locker).to_s
|
16
|
+
rescue Dialog::Cancelled
|
17
|
+
exit_with(:aborted, options.verbose)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Pwl
|
2
|
+
module Commands
|
3
|
+
class Get < Base
|
4
|
+
def call(args, options)
|
5
|
+
exit_with(:name_blank, options.verbose) if 0 == args.size || args[0].blank?
|
6
|
+
|
7
|
+
# second argument can be a field other than password
|
8
|
+
field = args.size > 1 ? args[1] : 'password'
|
9
|
+
|
10
|
+
begin
|
11
|
+
locker = open_locker(options)
|
12
|
+
result = attr!(locker.get(args[0]), field)
|
13
|
+
if result.blank?
|
14
|
+
exit_with(:no_value_found, options.verbose, :name => args[0])
|
15
|
+
else
|
16
|
+
puts result
|
17
|
+
end
|
18
|
+
rescue InacessibleFieldError
|
19
|
+
exit_with(:inaccessible_field, options.verbose, :field => field)
|
20
|
+
rescue Dialog::Cancelled
|
21
|
+
exit_with(:aborted, options.verbose)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Pwl
|
2
|
+
module Commands
|
3
|
+
class Init < Base
|
4
|
+
def call(args, options)
|
5
|
+
locker_file = locker_file(options, true)
|
6
|
+
msg "Attempting to initialize new locker at #{locker_file}" if options.verbose
|
7
|
+
|
8
|
+
# Locker checks this too, but we want to fail fast.
|
9
|
+
exit_with(:is_dir, options.verbose, :file => locker_file) if File.exists?(locker_file) && File.directory?(locker_file)
|
10
|
+
exit_with(:file_exists, options.verbose, :file => locker_file) if File.exists?(locker_file) && !options.force
|
11
|
+
|
12
|
+
begin
|
13
|
+
begin
|
14
|
+
master_password = get_password('Enter new master password:', options.gui)
|
15
|
+
end while begin
|
16
|
+
validate!(master_password) # Basic idea from http://stackoverflow.com/questions/136793/is-there-a-do-while-loop-in-ruby
|
17
|
+
rescue Pwl::InvalidMasterPasswordError => e
|
18
|
+
msg e.message
|
19
|
+
options.gui || STDIN.tty? # only continue the loop when in interactive mode
|
20
|
+
end
|
21
|
+
|
22
|
+
# Ask for password confirmation if running in interactive mode (terminal)
|
23
|
+
if STDIN.tty? && master_password != get_password('Enter master password again:', options.gui)
|
24
|
+
exit_with(:passwords_dont_match, options.verbose)
|
25
|
+
end
|
26
|
+
rescue Pwl::Dialog::Cancelled
|
27
|
+
exit_with(:aborted, options.verbose)
|
28
|
+
end
|
29
|
+
|
30
|
+
new_locker(options, master_password)
|
31
|
+
msg "Successfully initialized new locker at #{locker_file}" if options.verbose
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module Pwl
|
2
|
+
module Commands
|
3
|
+
class List < Base
|
4
|
+
def call(args, options)
|
5
|
+
options.default :separator => ' '
|
6
|
+
|
7
|
+
begin
|
8
|
+
locker = open_locker(options)
|
9
|
+
|
10
|
+
if !options.long
|
11
|
+
result = locker.list(args[0]).join(options.separator)
|
12
|
+
else
|
13
|
+
matching_names = locker.list(args[0])
|
14
|
+
|
15
|
+
result = "total #{matching_names.size}#{$/}"
|
16
|
+
|
17
|
+
matching_names.each do |name|
|
18
|
+
e = locker.get(name)
|
19
|
+
result << "#{e.uuid}\t#{e.name}#{$/}"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
if !result.blank?
|
24
|
+
puts result
|
25
|
+
else
|
26
|
+
if args[0] # filter given
|
27
|
+
exit_with(:list_empty_filter, options.verbose, args[0])
|
28
|
+
else
|
29
|
+
exit_with(:list_empty, options.verbose)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
rescue Dialog::Cancelled
|
33
|
+
exit_with(:aborted, options.verbose)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module Pwl
|
2
|
+
module Commands
|
3
|
+
class Passwd < Base
|
4
|
+
def call(args, options)
|
5
|
+
begin
|
6
|
+
locker = open_locker(options)
|
7
|
+
|
8
|
+
if !STDIN.tty? && !options.gui
|
9
|
+
# If we are in a pipe and do not run in GUI mode, we accept the new master password as args[0]
|
10
|
+
new_master_password = args[0]
|
11
|
+
|
12
|
+
begin
|
13
|
+
validate!(new_master_password)
|
14
|
+
rescue Pwl::InvalidMasterPasswordError => e
|
15
|
+
exit_with(:validation_new_failed, options.verbose, :message => e.message)
|
16
|
+
end
|
17
|
+
else
|
18
|
+
# If running interactively (console or gui), we loop until we get a valid password or break
|
19
|
+
begin
|
20
|
+
new_master_password = get_password("Enter the new master password for #{program(:name)}:", options.gui)
|
21
|
+
end while begin
|
22
|
+
validate!(new_master_password)
|
23
|
+
rescue Pwl::InvalidMasterPasswordError => e
|
24
|
+
msg e.message
|
25
|
+
options.gui || STDIN.tty? # only continue the loop when in interactive mode
|
26
|
+
end
|
27
|
+
|
28
|
+
# Confirm new password
|
29
|
+
if new_master_password != get_password("Enter the new master password for #{program(:name)} again:", options.gui)
|
30
|
+
exit_with(:passwords_dont_match, options.verbose)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
rescue Pwl::Dialog::Cancelled
|
34
|
+
exit_with(:aborted, options.verbose)
|
35
|
+
end
|
36
|
+
|
37
|
+
locker.change_password!(new_master_password)
|
38
|
+
msg "Successfully changed master password for #{program(:name)}." if options.verbose
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
module Pwl
|
2
|
+
module Commands
|
3
|
+
class UnknownFormatError < StandardError; end
|
4
|
+
|
5
|
+
module StatsPresenter
|
6
|
+
class Text
|
7
|
+
def present(locker)
|
8
|
+
puts "Created: #{locker.created}"
|
9
|
+
puts "Last accessed: #{locker.last_accessed}"
|
10
|
+
puts "Last modified: #{locker.last_modified}"
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class Html
|
15
|
+
def present(locker)
|
16
|
+
puts "TODO"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
class Json
|
21
|
+
def present(locker)
|
22
|
+
puts "TODO"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
class Yaml
|
27
|
+
def present(locker)
|
28
|
+
puts "TODO"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
class Stats < Base
|
34
|
+
def call(args, options)
|
35
|
+
locker = open_locker(options)
|
36
|
+
|
37
|
+
begin
|
38
|
+
puts presenter(options.format).present(locker)
|
39
|
+
rescue
|
40
|
+
exit_with(:unknown_format, options.verbose, :format => options.format)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def presenter(format)
|
47
|
+
if !format || format.is_a?(TrueClass) || 'text' == format
|
48
|
+
StatsPresenter::Text.new
|
49
|
+
else
|
50
|
+
begin
|
51
|
+
{:html => StatsPresenter::Html, :json => StatsPresenter::Json, :yaml => StatsPresenter::Yaml}[format.to_sym].new
|
52
|
+
rescue
|
53
|
+
raise UnknownFormatError.new(format)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
data/lib/pwl/entry.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'active_model'
|
2
|
+
require 'uuid'
|
3
|
+
|
4
|
+
module Pwl
|
5
|
+
class InvalidEntryError < StandardError
|
6
|
+
def initialize(errors)
|
7
|
+
super(errors.to_a.join(', '))
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
class Entry
|
12
|
+
attr_accessor :uuid, :name, :password
|
13
|
+
|
14
|
+
include ActiveModel::Validations
|
15
|
+
validates_presence_of :name, :uuid, :password
|
16
|
+
validates_format_of :uuid, :with => /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/
|
17
|
+
|
18
|
+
def initialize(name = nil)
|
19
|
+
@name = name
|
20
|
+
@uuid = UUID.generate
|
21
|
+
@password = nil
|
22
|
+
end
|
23
|
+
|
24
|
+
#
|
25
|
+
# raises InvalidEntryError if entry is not valid
|
26
|
+
#
|
27
|
+
def validate!
|
28
|
+
raise InvalidEntryError.new(errors) if invalid?
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
module Pwl
|
4
|
+
#
|
5
|
+
# DataMapper that maps an Entry from and to JSON
|
6
|
+
#
|
7
|
+
class EntryMapper
|
8
|
+
class << self
|
9
|
+
ATTRIBUTES = %w[uuid name password]
|
10
|
+
def from_json(str)
|
11
|
+
json = JSON(str, :symbolize_names => true)
|
12
|
+
|
13
|
+
Entry.new.tap do |entry|
|
14
|
+
ATTRIBUTES.each do |attr|
|
15
|
+
entry.send("#{attr}=", json[attr.to_sym])
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def to_json(entry)
|
21
|
+
entry.validate!
|
22
|
+
result = {}
|
23
|
+
ATTRIBUTES.each do |attr|
|
24
|
+
result.store(attr.to_sym, entry.send(attr))
|
25
|
+
end
|
26
|
+
result.to_json
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
data/lib/pwl/locker.rb
CHANGED
@@ -133,19 +133,26 @@ module Pwl
|
|
133
133
|
timestamp!(:last_accessed)
|
134
134
|
value = @backend[:user][encrypt(key)]
|
135
135
|
raise KeyNotFoundError.new(key) unless value
|
136
|
-
decrypt(value)
|
136
|
+
EntryMapper.from_json(decrypt(value))
|
137
137
|
}
|
138
138
|
end
|
139
139
|
|
140
140
|
#
|
141
|
-
# Store value
|
141
|
+
# Store entry or value under key
|
142
142
|
#
|
143
|
-
def add(
|
144
|
-
|
145
|
-
|
143
|
+
def add(entry_or_key, value = nil)
|
144
|
+
if value.nil? and entry_or_key.is_a?(Entry) # treat as entry
|
145
|
+
entry = entry_or_key
|
146
|
+
else
|
147
|
+
entry = Entry.new(entry_or_key)
|
148
|
+
entry.password = value
|
149
|
+
end
|
150
|
+
|
151
|
+
entry.validate!
|
152
|
+
|
146
153
|
@backend.transaction{
|
147
154
|
timestamp!(:last_modified)
|
148
|
-
@backend[:user][encrypt(
|
155
|
+
@backend[:user][encrypt(entry.name)] = encrypt(EntryMapper.to_json(entry))
|
149
156
|
}
|
150
157
|
end
|
151
158
|
|
@@ -158,7 +165,7 @@ module Pwl
|
|
158
165
|
timestamp!(:last_modified)
|
159
166
|
old_value = @backend[:user].delete(encrypt(key))
|
160
167
|
raise KeyNotFoundError.new(key) unless old_value
|
161
|
-
decrypt(old_value)
|
168
|
+
EntryMapper.from_json(decrypt(old_value))
|
162
169
|
}
|
163
170
|
end
|
164
171
|
|
@@ -178,12 +185,12 @@ module Pwl
|
|
178
185
|
end
|
179
186
|
|
180
187
|
#
|
181
|
-
# Return all entries
|
188
|
+
# Return all entries as array
|
182
189
|
#
|
183
190
|
def all
|
184
|
-
result =
|
191
|
+
result = []
|
185
192
|
@backend.transaction(true){
|
186
|
-
@backend[:user].each{|k,v| result
|
193
|
+
@backend[:user].each{|k,v| result << EntryMapper.from_json(decrypt(v))}
|
187
194
|
}
|
188
195
|
result
|
189
196
|
end
|
@@ -199,6 +206,7 @@ module Pwl
|
|
199
206
|
# Decrypt each key and value with the old master password and encrypt them with the new master password
|
200
207
|
copy = {}
|
201
208
|
@backend[:user].each{|k,v|
|
209
|
+
# No need to (de)serialize - the value comes in as JSON and goes out as JSON
|
202
210
|
new_key = Encryptor.encrypt(decrypt(k), :key => new_master_password)
|
203
211
|
new_val = Encryptor.encrypt(decrypt(v), :key => new_master_password)
|
204
212
|
copy[new_key] = new_val
|