pwl 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,27 @@
1
+ module Pwl
2
+ module Dialog
3
+ class GnomeDialog < SystemDialog
4
+ def command
5
+ "zenity --title \"#{title}\""
6
+ end
7
+ end
8
+
9
+ class GnomePasswordDialog < GnomeDialog
10
+ #
11
+ # Returns the OS command that is required to ask the user for a password.
12
+ #
13
+ def command
14
+ "#{super} --entry --hide-text --text \"#{prompt}\""
15
+ end
16
+ end
17
+
18
+ class GnomeTextDialog < GnomeDialog
19
+ #
20
+ # Returns the OS command that is required to ask the user for text input.
21
+ #
22
+ def command
23
+ "#{super} --entry --text \"#{prompt}\""
24
+ end
25
+ end
26
+ end
27
+ end
data/lib/pwl/dialog.rb ADDED
@@ -0,0 +1,50 @@
1
+ $:.unshift File.join(File.dirname(__FILE__), *%w[dialog])
2
+
3
+ require 'base'
4
+ require 'console'
5
+ require 'gnome'
6
+ require 'cocoa'
7
+
8
+ module Pwl
9
+ module Dialog
10
+ PLATFORM_PASSWORD_DIALOGS = {
11
+ :gnome => GnomePasswordDialog,
12
+ :mac => CocoaPasswordDialog,
13
+ }
14
+
15
+ PLATFORM_TEXT_DIALOGS = {
16
+ :gnome => GnomeTextDialog,
17
+ :mac => CocoaTextDialog,
18
+ }
19
+
20
+ extend self
21
+
22
+ class Password
23
+ class << self
24
+ # Factory method that creates a new password dialog that suits the current GUI platform.
25
+ # If no implementation was found for the current platform, a ConsolePasswordDialog is returned.
26
+ def new(title, prompt)
27
+ (PLATFORM_PASSWORD_DIALOGS[Dialog.gui_platform] || ConsolePasswordDialog).new(title, prompt)
28
+ end
29
+ end
30
+ end
31
+
32
+ class Text
33
+ class << self
34
+ # Factory method that creates a new text (input) dialog that suits the current GUI platform.
35
+ # If no implementation was found for the current platform, a ConsoleTextDialog is returned.
36
+ def new(title, prompt)
37
+ (PLATFORM_TEXT_DIALOGS[Dialog.gui_platform] || ConsoleTextDialog).new(title, prompt)
38
+ end
39
+ end
40
+ end
41
+
42
+ def gui_platform
43
+ if ENV['GDMSESSION']
44
+ :gnome
45
+ elsif RUBY_PLATFORM =~ /darwin/
46
+ :mac
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,46 @@
1
+ require "erb"
2
+
3
+ # http://refactormycode.com/codes/281-given-a-hash-of-variables-render-an-erb-template
4
+ class Hash
5
+ def to_binding(object = Object.new)
6
+ object.instance_eval("def binding_for(#{keys.join(",")}) binding end")
7
+ object.binding_for(*values)
8
+ end
9
+ end
10
+
11
+ module Pwl
12
+ class ReservedMessageCodeError < StandardError; end
13
+
14
+ class Message
15
+ attr_reader :exit_code
16
+
17
+ def initialize(template, exit_code = 0, default_replacements = {})
18
+ @template = ERB.new(template)
19
+ @exit_code = exit_code
20
+ @default_replacements = default_replacements
21
+ end
22
+
23
+ def to_s(replacements = {})
24
+ if !replacements.any? && @default_replacements.any?
25
+ @template.result(@default_replacements.to_binding)
26
+ else
27
+ @template.result(replacements.to_binding)
28
+ end
29
+ end
30
+
31
+ def error?
32
+ false
33
+ end
34
+ end
35
+
36
+ class ErrorMessage < Message
37
+ def initialize(template, exit_code, default_replacements = {})
38
+ raise ReservedMessageCodeError.new("Exit code 0 is reserved for success messages") if 0 == exit_code
39
+ super(template, exit_code, default_replacements)
40
+ end
41
+
42
+ def error?
43
+ true
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,29 @@
1
+ module Pwl
2
+ class InvalidMasterPasswordError < StandardError
3
+ def initialize(reason)
4
+ super("The master password is not valid: #{reason}")
5
+ end
6
+ end
7
+
8
+ class AnyNonEmptyPasswordPolicy
9
+ def validate!(master_password)
10
+ raise InvalidMasterPasswordError.new("Password must not be blank") if master_password.blank?
11
+ end
12
+ end
13
+
14
+ class ForbidAllPasswordPolicy
15
+ def validate!(master_password)
16
+ raise InvalidMasterPasswordError.new("No password allowed at all")
17
+ end
18
+ end
19
+
20
+ class ReasonableComplexityPasswordPolicy
21
+ def validate!(pwd)
22
+ raise InvalidMasterPasswordError.new("May not be blank") if pwd.blank?
23
+ raise InvalidMasterPasswordError.new("Must have at least eight characters") if 8 > pwd.length
24
+ raise InvalidMasterPasswordError.new("Must contain at least one integer") unless pwd =~ /.*\d/
25
+ raise InvalidMasterPasswordError.new("Must contain at least one character (lower or upper case)") unless pwd =~ /.*([a-z]|[A-Z])/
26
+ raise InvalidMasterPasswordError.new("Special characters are only allowed if their ASCII value is between 0x20 and 0x7E") unless pwd =~ /[\x20-\x7E]/
27
+ end
28
+ end
29
+ end
data/lib/pwl/store.rb ADDED
@@ -0,0 +1,284 @@
1
+ require 'date'
2
+
3
+ module Pwl
4
+ class Store
5
+ class FileAlreadyExistsError < StandardError
6
+ def initialize(file)
7
+ super("The file #{file} already exists")
8
+ end
9
+ end
10
+
11
+ class NotInitializedError < StandardError
12
+ def initialize(file)
13
+ super("The store at #{file} was not initialized yet")
14
+ end
15
+ end
16
+
17
+ class WrongMasterPasswordError < StandardError
18
+ def initialize
19
+ super("The master password is wrong")
20
+ end
21
+ end
22
+
23
+ class FileNotFoundError < StandardError
24
+ def initialize(file)
25
+ super("The file #{file} for the store was not found")
26
+ end
27
+ end
28
+
29
+ class KeyNotFoundError < StandardError
30
+ def initialize(key)
31
+ super("No entry was found for #{key}")
32
+ end
33
+ end
34
+
35
+ class BlankError < StandardError
36
+ def initialize(what)
37
+ super("#{what} is required")
38
+ end
39
+ end
40
+
41
+ class BlankKeyError < BlankError
42
+ def initialize
43
+ super("Key")
44
+ end
45
+ end
46
+
47
+ class BlankValueError < BlankError
48
+ def initialize
49
+ super("Value")
50
+ end
51
+ end
52
+
53
+ class << self
54
+ alias_method :load, :new
55
+
56
+ DEFAULT_PASSWORD_POLICY = ReasonableComplexityPasswordPolicy.new
57
+
58
+ #
59
+ # Constructs a new store (not only the object, but also the file behind it).
60
+ #
61
+ def new(file, master_password, options = {})
62
+ if File.exists?(file) && !options[:force] # don't allow accedidential override of existing file
63
+ raise FileAlreadyExistsError.new(file)
64
+ else
65
+ password_policy.validate!(master_password)
66
+ store = load(file, master_password)
67
+ store.reset!
68
+ end
69
+
70
+ store
71
+ end
72
+
73
+ #
74
+ # Opens an existing store. Throws if the backing file does not exist or isn't initialized.
75
+ #
76
+ def open(file, master_password)
77
+ raise FileNotFoundError.new(file) unless File.exists?(file)
78
+ store = load(file, master_password)
79
+ store.authenticate # do not allow openeing without successful authentication
80
+ store
81
+ end
82
+
83
+ def password_policy
84
+ @password_policy || DEFAULT_PASSWORD_POLICY
85
+ end
86
+
87
+ def password_policy=(policy)
88
+ @password_policy = policy
89
+ end
90
+ end
91
+
92
+ #
93
+ # Create a new store object by loading an existing file.
94
+ #
95
+ # Beware: New is overridden; it performs additional actions after before and after #initialize
96
+ #
97
+ def initialize(file, master_password)
98
+ @backend = PStore.new(file, true)
99
+ @backend.ultra_safe = true
100
+ @master_password = master_password
101
+ end
102
+
103
+ #
104
+ # (Re-) Initialize the database
105
+ #
106
+ def reset!
107
+ @backend.transaction{
108
+ @backend[:user] = {}
109
+ @backend[:system] = {}
110
+ @backend[:system][:created] = DateTime.now
111
+ @backend[:system][:salt] = encrypt(Random.rand.to_s)
112
+ }
113
+ end
114
+
115
+ #
116
+ # Check that the master password is correct. This is done to prevent opening an existing but blank store with the wrong password.
117
+ #
118
+ def authenticate
119
+ begin
120
+ @backend.transaction(true){
121
+ raise NotInitializedError.new(@backend.path.path) unless @backend[:user] && @backend[:system] && @backend[:system][:created]
122
+ check_salt!
123
+ }
124
+ rescue OpenSSL::Cipher::CipherError
125
+ raise WrongMasterPasswordError
126
+ end
127
+ end
128
+
129
+ #
130
+ # Return the value stored under key
131
+ #
132
+ def get(key)
133
+ raise BlankKeyError if key.blank?
134
+ @backend.transaction{
135
+ timestamp!(:last_accessed)
136
+ value = @backend[:user][encrypt(key)]
137
+ raise KeyNotFoundError.new(key) unless value
138
+ decrypt(value)
139
+ }
140
+ end
141
+
142
+ #
143
+ # Store value stored under key
144
+ #
145
+ def put(key, value)
146
+ raise BlankKeyError if key.blank?
147
+ raise BlankValueError if value.blank?
148
+ @backend.transaction{
149
+ timestamp!(:last_modified)
150
+ @backend[:user][encrypt(key)] = encrypt(value)
151
+ }
152
+ end
153
+
154
+ #
155
+ # Delete the value that is stored under key and return it
156
+ #
157
+ def delete(key)
158
+ raise BlankKeyError if key.blank?
159
+ @backend.transaction{
160
+ timestamp!(:last_modified)
161
+ old_value = @backend[:user].delete(encrypt(key))
162
+ raise KeyNotFoundError.new(key) unless old_value
163
+ decrypt(old_value)
164
+ }
165
+ end
166
+
167
+ #
168
+ # Return all keys, optionally filtered by filter
169
+ #
170
+ def list(filter = nil)
171
+ @backend.transaction(true){
172
+ result = @backend[:user].keys.collect{|k| decrypt(k)}
173
+
174
+ if filter.blank?
175
+ result
176
+ else
177
+ result.select{|k,v| k =~ /#{filter}/}
178
+ end
179
+ }
180
+ end
181
+
182
+ #
183
+ # Return all entries
184
+ #
185
+ def all
186
+ result = {}
187
+ @backend.transaction(true){
188
+ @backend[:user].each{|k,v| result[decrypt(k)] = decrypt(v)}
189
+ }
190
+ result
191
+ end
192
+
193
+ #
194
+ # Change the master password to +new_master_password+. Note that we don't take a password confirmation here.
195
+ # This is up to a UI layer.
196
+ #
197
+ def change_password!(new_master_password)
198
+ self.class.password_policy.validate!(new_master_password)
199
+
200
+ @backend.transaction{
201
+ # Decrypt each key and value with the old master password and encrypt them with the new master password
202
+ copy = {}
203
+ @backend[:user].each{|k,v|
204
+ new_key = Encryptor.encrypt(decrypt(k), :key => new_master_password)
205
+ new_val = Encryptor.encrypt(decrypt(v), :key => new_master_password)
206
+ copy[new_key] = new_val
207
+ }
208
+
209
+ # re-write user branch with newly encrypted keys and values
210
+ @backend[:user] = copy
211
+
212
+ # from now on, use the new master password as long as the object lives
213
+ @master_password = new_master_password
214
+
215
+ timestamp!(:last_modified)
216
+ @backend[:system][:salt] = encrypt(Random.rand.to_s)
217
+ }
218
+ end
219
+
220
+ #
221
+ # Return the date when the store was created
222
+ #
223
+ def created
224
+ @backend.transaction(true){@backend[:system][:created]}
225
+ end
226
+
227
+ #
228
+ # Return the date when the store was last accessed
229
+ #
230
+ def last_accessed
231
+ @backend.transaction(true){@backend[:system][:last_accessed]}
232
+ end
233
+
234
+ #
235
+ # Return the date when the store was last modified
236
+ #
237
+ def last_modified
238
+ @backend.transaction(true){@backend[:system][:last_modified]}
239
+ end
240
+
241
+ #
242
+ # Return the path to the file backing this store
243
+ #
244
+ def path
245
+ @backend.path
246
+ end
247
+
248
+ private
249
+
250
+ #
251
+ # Adds or updates the time-stamp stored under symbol
252
+ #
253
+ # This method must run within a PStore read/write transaction.
254
+ #
255
+ def timestamp!(sym)
256
+ @backend[:system][sym] = DateTime.now
257
+ end
258
+
259
+ #
260
+ # Return the encrypted +value+ (uses the current master password)
261
+ #
262
+ def encrypt(value)
263
+ Encryptor.encrypt(value, :key => @master_password)
264
+ end
265
+
266
+ #
267
+ # Return the decrypted +value+ (uses the current master password)
268
+ #
269
+ def decrypt(value)
270
+ Encryptor.decrypt(value, :key => @master_password)
271
+ end
272
+
273
+ #
274
+ # Attempts to decrypt the system salt. Throws if the master password is incorrect.
275
+ #
276
+ # This method must run within a PStore transaction (may be read-only).
277
+ #
278
+ def check_salt!
279
+ raise NotInitializedError.new(@backend.path.path) if @backend[:system][:salt].blank?
280
+ decrypt(@backend[:system][:salt])
281
+ nil
282
+ end
283
+ end
284
+ end
data/lib/pwl.rb ADDED
@@ -0,0 +1,14 @@
1
+ require 'pstore'
2
+ require 'encryptor'
3
+ require 'active_support/core_ext/object/blank'
4
+
5
+ $:.unshift File.join(File.dirname(__FILE__), *%w[pwl])
6
+
7
+ require 'message'
8
+ require 'password_policy'
9
+ require 'store'
10
+ require 'dialog'
11
+
12
+ module Pwl
13
+ VERSION = File.read(File.join(File.dirname(__FILE__), *%w[.. VERSION]))
14
+ end
data/pwl.gemspec ADDED
@@ -0,0 +1,94 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = "pwl"
8
+ s.version = "0.0.1"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Nicholas E. Rabenau"]
12
+ s.date = "2012-03-22"
13
+ s.description = "pwl is a secure password locker for the commandline"
14
+ s.email = "nerab@gmx.net"
15
+ s.executables = ["pwl", "pwl"]
16
+ s.extra_rdoc_files = [
17
+ "LICENSE.txt",
18
+ "README.md"
19
+ ]
20
+ s.files = [
21
+ ".document",
22
+ ".travis.yml",
23
+ "Gemfile",
24
+ "Gemfile.lock",
25
+ "LICENSE.txt",
26
+ "README.md",
27
+ "Rakefile",
28
+ "VERSION",
29
+ "bin/pwl",
30
+ "lib/pwl.rb",
31
+ "lib/pwl/dialog.rb",
32
+ "lib/pwl/dialog/base.rb",
33
+ "lib/pwl/dialog/cocoa.rb",
34
+ "lib/pwl/dialog/console.rb",
35
+ "lib/pwl/dialog/gnome.rb",
36
+ "lib/pwl/message.rb",
37
+ "lib/pwl/password_policy.rb",
38
+ "lib/pwl/store.rb",
39
+ "pwl.gemspec",
40
+ "templates/export.html.erb",
41
+ "test/acceptance/test_basics.rb",
42
+ "test/acceptance/test_delete.rb",
43
+ "test/acceptance/test_dialogs.rb",
44
+ "test/acceptance/test_export.rb",
45
+ "test/acceptance/test_get.rb",
46
+ "test/acceptance/test_init.rb",
47
+ "test/acceptance/test_list.rb",
48
+ "test/acceptance/test_passwd.rb",
49
+ "test/acceptance/test_put.rb",
50
+ "test/fixtures/test_all.html",
51
+ "test/fixtures/test_empty.html",
52
+ "test/helper.rb",
53
+ "test/unit/test_error.rb",
54
+ "test/unit/test_message.rb",
55
+ "test/unit/test_store_construction.rb",
56
+ "test/unit/test_store_crud.rb",
57
+ "test/unit/test_store_metadata.rb",
58
+ "test/unit/test_store_password_policy.rb",
59
+ "test/unit/test_store_security.rb"
60
+ ]
61
+ s.homepage = "http://github.com/nerab/pwl"
62
+ s.licenses = ["MIT"]
63
+ s.require_paths = ["lib"]
64
+ s.rubygems_version = "1.8.15"
65
+ s.summary = "Command-line password locker"
66
+
67
+ if s.respond_to? :specification_version then
68
+ s.specification_version = 3
69
+
70
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
71
+ s.add_runtime_dependency(%q<encryptor>, [">= 0"])
72
+ s.add_runtime_dependency(%q<commander>, [">= 0"])
73
+ s.add_runtime_dependency(%q<activesupport>, [">= 0"])
74
+ s.add_development_dependency(%q<rdoc>, ["~> 3.12"])
75
+ s.add_development_dependency(%q<bundler>, ["~> 1.0.0"])
76
+ s.add_development_dependency(%q<jeweler>, ["~> 1.8.3"])
77
+ else
78
+ s.add_dependency(%q<encryptor>, [">= 0"])
79
+ s.add_dependency(%q<commander>, [">= 0"])
80
+ s.add_dependency(%q<activesupport>, [">= 0"])
81
+ s.add_dependency(%q<rdoc>, ["~> 3.12"])
82
+ s.add_dependency(%q<bundler>, ["~> 1.0.0"])
83
+ s.add_dependency(%q<jeweler>, ["~> 1.8.3"])
84
+ end
85
+ else
86
+ s.add_dependency(%q<encryptor>, [">= 0"])
87
+ s.add_dependency(%q<commander>, [">= 0"])
88
+ s.add_dependency(%q<activesupport>, [">= 0"])
89
+ s.add_dependency(%q<rdoc>, ["~> 3.12"])
90
+ s.add_dependency(%q<bundler>, ["~> 1.0.0"])
91
+ s.add_dependency(%q<jeweler>, ["~> 1.8.3"])
92
+ end
93
+ end
94
+
@@ -0,0 +1,61 @@
1
+ <!DOCTYPE HTML>
2
+ <html>
3
+ <head>
4
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
5
+ <title>Password Manager Export</title>
6
+ <style type="text/css">
7
+ table { page-break-inside:auto}
8
+ tr { page-break-inside:avoid; page-break-after:auto}
9
+ thead { display:table-header-group}
10
+ tfoot { display:table-footer-group; font-size: 0.85em;}
11
+
12
+ /* adapted from http://coding.smashingmagazine.com/2008/08/13/top-10-css-table-designs/ */
13
+ body{
14
+ font-family: "Lucida Sans Unicode", "Lucida Grande", Sans-Serif;
15
+ font-size: 12px;
16
+ margin: 45px;
17
+ width: 480px;
18
+ border-collapse: collapse;
19
+ text-align: left;
20
+ }
21
+ th{
22
+ font-size: 14px;
23
+ font-weight: bold;
24
+ padding: 10px 8px;
25
+ border-bottom: 2px solid;
26
+ }
27
+ td{
28
+ border-bottom: 1px solid;
29
+ padding: 6px 8px;
30
+ }
31
+ </style>
32
+ </head>
33
+ <body>
34
+ <h1>Password Manager Export</h1>
35
+ <table>
36
+ <thead>
37
+ <tr>
38
+ <th>Name</th>
39
+ <th>Value</th>
40
+ </tr>
41
+ </thead>
42
+ <tfoot>
43
+ <tr>
44
+ <td colspan="2">
45
+ This <a href="http://rdoc.info/github/nerab/pwl/master/frames">pwl</a> export was created on <%= DateTime.now.strftime('%F %R') %>.
46
+ <br/>
47
+ The database at <%= store.path %> was <%= "last modified #{store.last_modified.strftime('%F %R')}" rescue 'never modified' %>.
48
+ </td>
49
+ </tr>
50
+ </tfoot>
51
+ <tbody>
52
+ <% store.all.each{|key, value|%>
53
+ <tr>
54
+ <td><%= key %></td>
55
+ <td><%= value %></td>
56
+ </tr>
57
+ <% } %>
58
+ </tbody>
59
+ </table>
60
+ </body>
61
+ </html>
@@ -0,0 +1,15 @@
1
+ require 'helper'
2
+
3
+ class TestBasics < Test::Pwl::AppTestCase
4
+ def test_help
5
+ assert_successful('Rabenau', 'help')
6
+ end
7
+
8
+ def test_no_args
9
+ assert_error('invalid', '')
10
+ end
11
+
12
+ def test_unknown_command
13
+ assert_error('invalid', 'foobar')
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ require 'helper'
2
+
3
+ # Tests `pwl delete`
4
+ class TestDelete < Test::Pwl::AppTestCase
5
+ def test_delete_blank_key
6
+ assert_error('may not be blank', 'delete')
7
+ end
8
+
9
+ def test_delete_simple
10
+ assert_successful('', 'put foo bar')
11
+ assert_successful('', 'delete foo')
12
+ assert_error('No entry was found for foo', 'get foo')
13
+ end
14
+ end
@@ -0,0 +1,45 @@
1
+ require 'helper'
2
+
3
+ #
4
+ # Unit tests for dialogs
5
+ #
6
+ # Note that these teste are being excluded when the plain +rake+ file is run because these tests require manual
7
+ # intervention.
8
+ #
9
+ # Run these tests separately with
10
+ #
11
+ # rake TEST=test/acceptance/test_dialogs.rb
12
+ #
13
+ class TestPasswordDialog < Test::Unit::TestCase
14
+ CANDIDATES = %w[Homer Marge Bart Lisa Maggie Martin Ralph]
15
+
16
+ def test_get_with_spaces
17
+ expected = "#{CANDIDATES[Random.rand(CANDIDATES.size)]} #{CANDIDATES[Random.rand(CANDIDATES.size)]}"
18
+ get_text(expected, Pwl::Dialog::Password)
19
+ end
20
+
21
+ def test_get_empty
22
+ assert_equal('', Pwl::Dialog::Password.new(self.class.name, "Please just press Enter without entering any text.").get_input)
23
+ end
24
+
25
+ def test_get_without_spaces
26
+ expected = CANDIDATES[Random.rand(CANDIDATES.size)]
27
+ get_text(expected, Pwl::Dialog::Password)
28
+ end
29
+
30
+ def test_cancel
31
+ assert_raise Pwl::Dialog::Cancelled do
32
+ Pwl::Dialog::Password.new(self.class.name, "Please press the Escape key.").get_input
33
+ end
34
+
35
+ assert_raise Pwl::Dialog::Cancelled do
36
+ Pwl::Dialog::Password.new(self.class.name, "Please press the Cancel button.").get_input
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def get_text(expected, dlg_class)
43
+ assert_equal(expected, dlg_class.new(self.class.name, "Please enter the string '#{expected}' (without the quotes) and press Enter.").get_input)
44
+ end
45
+ end