pwl 0.0.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.
@@ -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