pwl 0.0.2 → 0.0.3
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/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
|