pwkeep 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.
data/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ .bundle
2
+ pkg/
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ gemspec
4
+ gem 'rake'
data/Gemfile.lock ADDED
@@ -0,0 +1,41 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ pwkeep (0.0.1)
5
+ bundler
6
+ colorize
7
+ hashr
8
+ highline
9
+ keepass-password-generator
10
+ lockfile
11
+ ruco
12
+ trollop
13
+
14
+ GEM
15
+ remote: http://rubygems.org/
16
+ specs:
17
+ clipboard (1.0.5)
18
+ colorize (0.6.0)
19
+ dispel (0.0.3)
20
+ hashr (0.0.22)
21
+ highline (1.6.20)
22
+ keepass-password-generator (0.1.1)
23
+ language_sniffer (1.0.2)
24
+ lockfile (2.1.0)
25
+ plist (3.1.0)
26
+ rake (10.1.1)
27
+ ruco (0.2.19)
28
+ clipboard (>= 0.9.8)
29
+ dispel
30
+ language_sniffer
31
+ textpow (>= 1.3.0)
32
+ textpow (1.3.1)
33
+ plist (>= 3.0.1)
34
+ trollop (2.0)
35
+
36
+ PLATFORMS
37
+ ruby
38
+
39
+ DEPENDENCIES
40
+ pwkeep!
41
+ rake
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2014 Aki Tuomi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,68 @@
1
+ PassWord Keep (pwkeep)
2
+ ======================
3
+
4
+ Simple password storage system.
5
+
6
+ Quick start guide
7
+ =================
8
+
9
+ Run pwkeep -i to initialize a new storage into ~/.pwkeep. This will create an RSA key pair.
10
+
11
+ If you want to tune the algorithm(s), key sizes and such, you can create ~/.pwkeep/config.yml (see below for syntax).
12
+
13
+ If you want to place it somewhere else, set PWKEEP\_HOME environment variable, or use -H (--home) parameter when running pwkeep.
14
+
15
+ To add credentials, use
16
+
17
+ pwkeep -c -n <name of cred>
18
+
19
+ To modify them
20
+
21
+ pwkeep -e -n <name>
22
+
23
+ And to show
24
+
25
+ pwkeep -v -n <name>
26
+
27
+ See --help for more options.
28
+
29
+ Features
30
+ ========
31
+
32
+ Password keep is intended to be simple and easy to use. It uses RSA + AES256 encryption for your credentials. The
33
+ data is not restricted to usernames and passwords, you can store whatever you want.
34
+
35
+ Editing is done with embedded ruco text editor using memory-only backing. No temporary files are used.
36
+
37
+ Configuration
38
+ =============
39
+
40
+ The configuration file is a simple YAML formatted file with following syntax (*NOT YET SUPPORTED*)
41
+
42
+ ```yaml
43
+ ---
44
+ # less than 1k makes no sense. your files will be at least this / 8 bytes.
45
+ keysize: 2048
46
+ iterations: 2000
47
+ # do not edit the following unless you know what you are doing.
48
+ cipher: AES-256-CTR
49
+ ```
50
+
51
+ File formats
52
+ ============
53
+
54
+ The private.pem file contains your private key. It is fully manipulatable with openssl binary without any specialities.
55
+
56
+ system-\* files contain actual credentials. The file name consists from system- prefix and hashed system name. The system
57
+ name is hashed iterations time with chosen hash, SHA512 by default.
58
+
59
+ The actual file format is:
60
+
61
+ * header (encrypted with your public key)
62
+ * nil terminated algorithm name
63
+ * 16 byte iv (algorithm dependant)
64
+ * 32 byte key (algorithm dependant)
65
+ * data: encrypted credential with above key+id
66
+
67
+ You cannot decrypt this with openssl directly, but you can easily write a program to do this. The header is padded with OAEP
68
+ padding.
data/Rakefile ADDED
@@ -0,0 +1,2 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
data/bin/pwkeep ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # vim:syntax=ruby:
3
+ require 'bundler/setup'
4
+ require 'pwkeep'
5
+
6
+ PWKeep::Main.run
data/lib/pwkeep.rb ADDED
@@ -0,0 +1,31 @@
1
+ require 'bundler/setup'
2
+ require 'trollop'
3
+ require 'digest/sha2'
4
+ require 'logger'
5
+ require 'pathname'
6
+ require 'colorize'
7
+ require 'lockfile'
8
+ require 'openssl'
9
+ require 'ruco'
10
+
11
+ module PWKeep
12
+ extend self
13
+
14
+ def logger
15
+ unless @logger
16
+ @logger = Logger.new(STDOUT)
17
+ @logger.formatter = proc do |severity, datetime, progname, msg|
18
+ "#{msg}\n"
19
+ end
20
+ end
21
+ @logger
22
+ end
23
+
24
+ class Exception < ::Exception
25
+ end
26
+ end
27
+
28
+ require 'pwkeep/main'
29
+ require 'pwkeep/generator'
30
+ require 'pwkeep/storage'
31
+ require 'pwkeep/editor'
@@ -0,0 +1,10 @@
1
+ require 'yaml'
2
+ require 'singleton'
3
+
4
+ module PWKeep
5
+ class Config < Hashr
6
+ def load(file)
7
+ self.merge YAML.load_file(file)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,462 @@
1
+ require 'clipboard'
2
+
3
+ module PWKeep
4
+ def self.run_editor(data, options)
5
+ ret = [false, data]
6
+
7
+ Dispel::Screen.open do |screen|
8
+ $ruco_screen = screen
9
+ app = PWKeep::EditorApplication.new(data.to_s,
10
+ :lines => screen.lines, :columns => screen.columns
11
+ )
12
+
13
+ screen.draw *app.display_info
14
+
15
+ Dispel::Keyboard.output do |key|
16
+ if key == :resize
17
+ app.resize(screen.lines, screen.columns)
18
+ else
19
+ result = app.key key
20
+ if result == :quit
21
+ ret = [app.editor.saved_content != data, app.editor.saved_content]
22
+ break
23
+ end
24
+ end
25
+
26
+ screen.draw *app.display_info
27
+ end
28
+ end
29
+
30
+ return ret
31
+ end
32
+
33
+ class EditorApplication
34
+ attr_reader :editor, :status, :command, :options
35
+
36
+ def initialize(data, options)
37
+ @options = Ruco::OptionAccessor.new(options)
38
+ @data = data
39
+
40
+ setup_actions
41
+ setup_keys
42
+ create_components
43
+ end
44
+
45
+ def display_info
46
+ [view, style_map, cursor]
47
+ end
48
+
49
+ def view
50
+ [status.view, editor.view, command.view].join("\n")
51
+ end
52
+
53
+ def style_map
54
+ status.style_map + editor.style_map + command.style_map
55
+ end
56
+
57
+ def cursor
58
+ Ruco::Position.new(@focused.cursor.line + @status_lines, @focused.cursor.column)
59
+ end
60
+
61
+ # user typed a key
62
+ def key(key)
63
+ # deactivate select_mode if its not re-enabled in this action
64
+ @select_mode_was_on = @select_mode
65
+ @select_mode = false
66
+
67
+ if bound = @bindings[key]
68
+ return execute_action(bound)
69
+ end
70
+
71
+ case key
72
+
73
+ # move
74
+ when :down then move_with_select_mode :relative, 1,0
75
+ when :right then move_with_select_mode :relative, 0,1
76
+ when :up then move_with_select_mode :relative, -1,0
77
+ when :left then move_with_select_mode :relative, 0,-1
78
+ when :page_up then move_with_select_mode :page_up
79
+ when :page_down then move_with_select_mode :page_down
80
+ when :"Ctrl+right", :"Alt+f" then move_with_select_mode :jump, :right
81
+ when :"Ctrl+left", :"Alt+b" then move_with_select_mode :jump, :left
82
+
83
+ # select
84
+ when :"Shift+down" then @focused.selecting { move(:relative, 1, 0) }
85
+ when :"Shift+right" then @focused.selecting { move(:relative, 0, 1) }
86
+ when :"Shift+up" then @focused.selecting { move(:relative, -1, 0) }
87
+ when :"Shift+left" then @focused.selecting { move(:relative, 0, -1) }
88
+ when :"Ctrl+Shift+left", :"Alt+Shift+left" then @focused.selecting{ move(:jump, :left) }
89
+ when :"Ctrl+Shift+right", :"Alt+Shift+right" then @focused.selecting{ move(:jump, :right) }
90
+ when :"Shift+end" then @focused.selecting{ move(:to_eol) }
91
+ when :"Shift+home" then @focused.selecting{ move(:to_bol) }
92
+
93
+
94
+ # modify
95
+ when :tab then
96
+ if @editor.selection
97
+ @editor.indent
98
+ else
99
+ @focused.insert("\t")
100
+ end
101
+ when :"Shift+tab" then @editor.unindent
102
+ when :enter then @focused.insert("\n")
103
+ when :backspace then @focused.delete(-1)
104
+ when :delete then @focused.delete(1)
105
+
106
+ when :escape then # escape from focused
107
+ @focused.reset
108
+ @focused = editor
109
+ else
110
+ @focused.insert(key) if key.is_a?(String)
111
+ end
112
+ end
113
+
114
+ def bind(key, action=nil, &block)
115
+ raise "Ctrl+m cannot be bound" if key == :"Ctrl+m" # would shadow enter -> bad
116
+ raise "Cannot bind an action and a block" if action and block
117
+ @bindings[key] = action || block
118
+ end
119
+
120
+ def action(name, &block)
121
+ @actions[name] = block
122
+ end
123
+
124
+ def ask(question, options={}, &block)
125
+ @focused = command
126
+ command.ask(question, options) do |response|
127
+ @focused = editor
128
+ block.call(response)
129
+ end
130
+ end
131
+
132
+ def loop_ask(question, options={}, &block)
133
+ ask(question, options) do |result|
134
+ finished = (block.call(result) == :finished)
135
+ loop_ask(question, options, &block) unless finished
136
+ end
137
+ end
138
+
139
+ def configure(&block)
140
+ instance_exec(&block)
141
+ end
142
+
143
+ def resize(lines, columns)
144
+ @options[:lines] = lines
145
+ @options[:columns] = columns
146
+ create_components
147
+ @editor.resize(editor_lines, columns)
148
+ end
149
+
150
+ private
151
+
152
+ def setup_actions
153
+ @actions = {}
154
+
155
+ action :paste do
156
+ @focused.insert(Clipboard.paste)
157
+ end
158
+
159
+ action :copy do
160
+ Clipboard.copy(@focused.text_in_selection)
161
+ end
162
+
163
+ action :cut do
164
+ Clipboard.copy(@focused.text_in_selection)
165
+ @focused.delete(0)
166
+ end
167
+
168
+ action :save do
169
+ result = editor.save
170
+ if result != true
171
+ ask("#{result.slice(0,100)} -- Enter=Retry Esc=Cancel "){ @actions[:save].call }
172
+ end
173
+ end
174
+
175
+ action :quit do
176
+ if editor.modified?
177
+ ask("Lose changes? Enter=Yes Esc=Cancel") do
178
+ editor.store_session
179
+ :quit
180
+ end
181
+ else
182
+ editor.store_session
183
+ :quit
184
+ end
185
+ end
186
+
187
+ action :go_to_line do
188
+ ask('Go to Line: ') do |result|
189
+ editor.move(:to_line, result.to_i - 1)
190
+ end
191
+ end
192
+
193
+ action :delete_line do
194
+ editor.delete_line
195
+ end
196
+
197
+ action :select_mode do
198
+ @select_mode = !@select_mode_was_on
199
+ end
200
+
201
+ action :select_all do
202
+ @focused.move(:to, 0, 0)
203
+ @focused.selecting do
204
+ move(:to, 9999, 9999)
205
+ end
206
+ end
207
+
208
+ action :find do
209
+ ask("Find: ", :cache => true) do |result|
210
+ next if editor.find(result)
211
+
212
+ if editor.content.include?(result)
213
+ ask("No matches found -- Enter=First match ESC=Stop") do
214
+ editor.move(:to, 0,0)
215
+ editor.find(result)
216
+ end
217
+ else
218
+ ask("No matches found in entire file", :auto_enter => true){}
219
+ end
220
+ end
221
+ end
222
+
223
+ action :find_and_replace do
224
+ ask("Find: ", :cache => true) do |term|
225
+ if editor.find(term)
226
+ ask("Replace with: ", :cache => true) do |replace|
227
+ loop_ask("Replace=Enter Skip=s All=a Cancel=Esc", :auto_enter => true) do |ok|
228
+ case ok
229
+ when '' # enter
230
+ editor.insert(replace)
231
+ when 'a'
232
+ stop = true
233
+ editor.insert(replace)
234
+ editor.insert(replace) while editor.find(term)
235
+ when 's' # do nothing
236
+ else
237
+ stop = true
238
+ end
239
+
240
+ :finished if stop or not editor.find(term)
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
246
+
247
+ action(:undo){ @editor.undo if @focused == @editor }
248
+ action(:redo){ @editor.redo if @focused == @editor }
249
+ action(:move_line_up){ @editor.move_line(-1) if @focused == @editor }
250
+ action(:move_line_down){ @editor.move_line(1) if @focused == @editor }
251
+
252
+ action(:move_to_eol){ move_with_select_mode :to_eol }
253
+ action(:move_to_bol){ move_with_select_mode :to_bol }
254
+
255
+ action(:insert_hash_rocket){ @editor.insert(' => ') }
256
+ end
257
+
258
+ def setup_keys
259
+ @bindings = {}
260
+ bind :"Ctrl+s", :save
261
+ bind :"Ctrl+w", :quit
262
+ bind :"Ctrl+q", :quit
263
+ bind :"Ctrl+g", :go_to_line
264
+ bind :"Ctrl+f", :find
265
+ bind :"Ctrl+r", :find_and_replace
266
+ bind :"Ctrl+b", :select_mode
267
+ bind :"Ctrl+a", :select_all
268
+ bind :"Ctrl+d", :delete_line
269
+ bind :"Ctrl+l", :insert_hash_rocket
270
+ bind :"Ctrl+x", :cut
271
+ bind :"Ctrl+c", :copy
272
+ bind :"Ctrl+v", :paste
273
+ bind :"Ctrl+z", :undo
274
+ bind :"Ctrl+y", :redo
275
+ bind :"Alt+Ctrl+down", :move_line_down
276
+ bind :"Alt+Ctrl+up", :move_line_up
277
+ bind :end, :move_to_eol
278
+ bind :"Ctrl+e", :move_to_eol # for OsX
279
+ bind :home, :move_to_bol
280
+ end
281
+
282
+ def load_user_config
283
+ Ruco.application = self
284
+ config = File.expand_path(@options[:rc] || "~/.ruco.rb")
285
+ load config if File.exist?(config)
286
+ end
287
+
288
+ def create_components
289
+ @status_lines = 1
290
+
291
+ editor_options = @options.slice(
292
+ :columns, :convert_tabs, :convert_newlines, :undo_stack_size, :color_theme
293
+ ).merge(
294
+ :window => @options.nested(:window),
295
+ :history => @options.nested(:history),
296
+ :lines => editor_lines
297
+ ).merge(@options.nested(:editor))
298
+
299
+ @editor ||= PWKeep::Editor.new(@data, editor_options)
300
+ @status = PWKeep::StatusBar.new(@editor, @options.nested(:status_bar).merge(:columns => options[:columns]))
301
+ @command = Ruco::CommandBar.new(@options.nested(:command_bar).merge(:columns => options[:columns]))
302
+ command.cursor_line = editor_lines
303
+ @focused = @editor
304
+ end
305
+
306
+ def editor_lines
307
+ command_lines = 1
308
+ @options[:lines] - @status_lines - command_lines
309
+ end
310
+
311
+ def parse_file_and_line(file)
312
+ if file.to_s.include?(':') and not File.exist?(file)
313
+ short_file, go_to_line = file.split(':',2)
314
+ if File.exist?(short_file)
315
+ file = short_file
316
+ else
317
+ go_to_line = nil
318
+ end
319
+ end
320
+ [file, go_to_line]
321
+ end
322
+
323
+ def move_with_select_mode(*args)
324
+ @select_mode = true if @select_mode_was_on
325
+ if @select_mode
326
+ @focused.selecting do
327
+ move(*args)
328
+ end
329
+ else
330
+ @focused.send(:move, *args)
331
+ end
332
+ end
333
+
334
+ def execute_action(action)
335
+ if action.is_a?(Symbol)
336
+ @actions[action].call
337
+ else
338
+ action.call
339
+ end
340
+ end
341
+ end
342
+
343
+ class Editor
344
+ attr_reader :file
345
+ attr_reader :text_area
346
+ attr_reader :history
347
+ attr_reader :saved_content
348
+
349
+ private :text_area
350
+ delegate :view, :style_map, :cursor, :position,
351
+ :insert, :indent, :unindent, :delete, :delete_line,
352
+ :redo, :undo, :save_state,
353
+ :selecting, :selection, :text_in_selection, :reset,
354
+ :move, :resize, :move_line,
355
+ :to => :text_area
356
+
357
+ def initialize(data, options)
358
+ @options = options
359
+
360
+ content = data
361
+ @options[:language] ||= LanguageSniffer.detect(@file, :content => content).language
362
+ content.tabs_to_spaces! if @options[:convert_tabs]
363
+
364
+ # cleanup newline formats
365
+ @newline = content.match("\r\n|\r|\n")
366
+ @newline = (@newline ? @newline[0] : "\n")
367
+ content.gsub!(/\r\n?/,"\n")
368
+
369
+ @saved_content = content
370
+ @text_area = Ruco::EditorArea.new(content, @options)
371
+ @history = @text_area.history
372
+ restore_session
373
+ end
374
+
375
+ def find(text)
376
+ move(:relative, 0,0) # reset selection
377
+ start = text_area.content.index(text, text_area.index_for_position+1)
378
+ return unless start
379
+
380
+ # select the found word
381
+ finish = start + text.size
382
+ move(:to_index, finish)
383
+ selecting{ move(:to_index, start) }
384
+
385
+ true
386
+ end
387
+
388
+ def modified?
389
+ @saved_content != text_area.content
390
+ end
391
+
392
+ def save
393
+ lines = text_area.send(:lines)
394
+ lines.each(&:rstrip!) if @options[:remove_trailing_whitespace_on_save]
395
+ lines << '' if @options[:blank_line_before_eof_on_save] and lines.last.to_s !~ /^\s*$/
396
+ content = lines * @newline
397
+
398
+ @saved_content = content.gsub(/\r?\n/, "\n")
399
+
400
+ true
401
+ rescue Object => e
402
+ e.message
403
+ end
404
+
405
+ def store_session
406
+ end
407
+
408
+ def content
409
+ text_area.content.freeze # no modifications allowed
410
+ end
411
+
412
+ private
413
+
414
+ def restore_session
415
+ end
416
+
417
+ def session_store
418
+ end
419
+ end
420
+
421
+ class StatusBar
422
+ def initialize(editor, options)
423
+ @editor = editor
424
+ @options = options
425
+ end
426
+
427
+ def view
428
+ columns = @options[:columns]
429
+
430
+ version = "Ruco #{Ruco::VERSION} -- "
431
+ position = " #{@editor.position.line + 1}:#{@editor.position.column + 1}"
432
+ indicators = "#{change_indicator}#{writable_indicator}"
433
+ essential = version + position + indicators
434
+ space_left = [columns - essential.size, 0].max
435
+
436
+ # fit file name into remaining space
437
+ "#{version}#{indicators}#{' ' * space_left}#{position}"[0, columns]
438
+ end
439
+
440
+ def style_map
441
+ Dispel::StyleMap.single_line_reversed(@options[:columns])
442
+ end
443
+
444
+ def change_indicator
445
+ @editor.modified? ? '*' : ' '
446
+ end
447
+
448
+ def writable_indicator
449
+ true
450
+ end
451
+
452
+ private
453
+
454
+ # fill the line with left column and then overwrite the right section
455
+ def spread(left, right)
456
+ empty = [@options[:columns] - left.size, 0].max
457
+ line = left + (" " * empty)
458
+ line[(@options[:columns] - right.size - 1)..-1] = ' ' + right
459
+ line
460
+ end
461
+ end
462
+ end
File without changes
@@ -0,0 +1,174 @@
1
+ require 'highline/import'
2
+ require 'keepass/password'
3
+
4
+ module PWKeep
5
+ class Main
6
+ attr :opts
7
+
8
+ def initialize
9
+ @opts = { :home => ENV['PWKEEP_HOME'] || '~/.pwkeep' } # required value
10
+ end
11
+
12
+ def setup
13
+ @opts = Trollop::options do
14
+ version "0.0.1 (c) 2014 Aki Tuomi"
15
+ banner <<-EOS
16
+ This program is a simple password storage utility. Distributed under MIT license. NO WARRANTY.
17
+
18
+ Usage:
19
+ #{$0} [options]
20
+
21
+ Where [options] are
22
+
23
+ EOS
24
+ opt :system, "System name", :type => :string, :short => '-n'
25
+ opt :initialize, "Initialize storage at ~/.pwkeep (you can change this with PWKEEP_HOME or --home)", :short => '-i'
26
+ opt :create, "Create new entry", :short => '-c'
27
+ opt :view, "View entry", :short => '-v'
28
+ opt :edit, "Edit entry", :short => '-e'
29
+ opt :delete, "Delete entry", :short => '-d'
30
+ opt :search, "Search for system or username", :type => :string, :short => '-s'
31
+ opt :list, "List all known systems", :short => '-l'
32
+ opt :help, "Show usage", :short => '-h'
33
+ opt :home, "Home directory", :short => '-H', :type => :string, :default => ( ENV['PWKEEP_HOME'] || '~/.pwkeep' )
34
+ opt :version, "Show version", :short => '-V'
35
+ end
36
+
37
+ # validate options
38
+ Trollop::die :system, "must be given for create/show/edit/delete" if opts[:system].nil? and (opts[:edit] or opts[:view] or opts[:delete] or opts[:create])
39
+ Trollop::die :create, "can only have one mode of operation" if opts[:create] and (opts[:edit] or opts[:view] or opts[:delete] or opts[:search] or opts[:initialize] or opts[:list])
40
+ Trollop::die :edit, "can only have one mode of operation" if opts[:edit] and (opts[:create] or opts[:view] or opts[:delete] or opts[:search] or opts[:initialize] or opts[:list])
41
+ Trollop::die :view, "can only have one mode of operation" if opts[:view] and (opts[:edit] or opts[:create] or opts[:delete] or opts[:search] or opts[:initialize] or opts[:list])
42
+ Trollop::die :delete, "can only have one mode of operation" if opts[:delete] and (opts[:edit] or opts[:view] or opts[:create] or opts[:search] or opts[:initialize] or opts[:list])
43
+ Trollop::die :search, "can only have one mode of operation" if opts[:search] and (opts[:edit] or opts[:view] or opts[:delete] or opts[:create] or opts[:initialize] or opts[:list])
44
+ Trollop::die :initialize, "can only have one mode of operation" if opts[:initialize] and (opts[:edit] or opts[:view] or opts[:delete] or opts[:create] or opts[:search] or opts[:list])
45
+ Trollop::die :list, "can only have one mode of operation" if opts[:list] and (opts[:edit] or opts[:view] or opts[:delete] or opts[:create] or opts[:search] or opts[:initialize])
46
+
47
+ Trollop::die "You must choose one mode of operation" unless opts[:create] or opts[:edit] or opts[:view] or opts[:delete] or opts[:search] or opts[:initialize] or opts[:list]
48
+ end
49
+
50
+ def self.run
51
+ Main.new.run
52
+ end
53
+
54
+ def load_config
55
+ config_file = Pathname.new(@opts[:home]).expand_path.join('config.yml')
56
+ if config_file.exist?
57
+ PWKeep::Config.instance.load(config_file)
58
+ end
59
+ end
60
+
61
+ def run
62
+ setup
63
+ load_config
64
+ @storage = PWKeep::Storage.new(:path => opts[:home])
65
+
66
+ begin
67
+ if opts[:initialize]
68
+ @storage.create
69
+
70
+ if @storage.valid?
71
+ say("<%= color('WARNING!', BOLD) %> a valid pwkeep storage was found!")
72
+ say("If you continue, the existing storage becomes <%= color('UNUSABLE', BOLD) %>")
73
+ unless agree("Continue (y/n)? ")
74
+ raise PWKeep::Exception, "Storage initialization aborted"
75
+ end
76
+ end
77
+
78
+ # create a keypair
79
+ pw_a = ""
80
+ pw_b = ""
81
+ while(pw_a == "" or pw_a != pw_b)
82
+ pw_a = ask("Enter your password: ") { |q| q.echo = false }
83
+ pw_b = ask("Confirm your password: ") { |q| q.echo = false }
84
+ say("<%= color('Passwords did not match', RED %>") unless pw_a == pw_b
85
+ end
86
+ @storage.keypair_create(pw_b)
87
+ # this concludes initialization
88
+ say("<%= color('Password storage initialized', GREEN %>")
89
+ return
90
+ end
91
+
92
+ raise "Storage not initialized (run with --initialize)" unless @storage.valid?
93
+
94
+ if opts[:view]
95
+ pw = ask("Enter your password:") { |q| q.echo = false }
96
+ @storage.keypair_load pw
97
+
98
+ data = @storage.load_system opts[:system]
99
+
100
+ say("Last edited: #{data[:stored_at]}\nSystem: #{data[:system]}\n\n")
101
+ say(data[:data])
102
+ return
103
+ end
104
+
105
+ if opts[:create]
106
+ data = "Username: \nPassword: #{KeePass::Password.generate('uullA{6}')}"
107
+
108
+ result = PWKeep.run_editor(data, {})
109
+
110
+ unless result[0]
111
+ raise "Not modified"
112
+ end
113
+
114
+ pw = ask("Enter your password:") { |q| q.echo = false }
115
+ @storage.keypair_load pw
116
+ @storage.save_system opts[:system], result[1]
117
+ say("<%= color('Changes stored', GREEN)%>")
118
+ return
119
+ end
120
+
121
+ if opts[:edit]
122
+ pw = ask("Enter your password:") { |q| q.echo = false }
123
+ @storage.keypair_load pw
124
+ data = @storage.load_system opts[:system]
125
+ result = PWKeep.run_editor(data[:data], {})
126
+ unless result[0]
127
+ raise "Not modified"
128
+ end
129
+ @storage.save_system opts[:system], result[1]
130
+ say("<%= color('Changes stored', GREEN)%>")
131
+ return
132
+ end
133
+
134
+ if opts[:delete]
135
+ pw = ask("Enter your password:") { |q| q.echo = false }
136
+ @storage.keypair_load pw
137
+ data = @storage.load_system opts[:system]
138
+ # just to be sure
139
+ unless agree("Are you <%=color('SURE',BOLD)%> you want to delete #{data[:system]}?")
140
+ @storage.delete data[:system]
141
+ end
142
+ say("<%= color('System deleted', YELLOW)%>")
143
+ return
144
+ end
145
+
146
+ if opts[:search]
147
+ pw = ask("Enter your password:") { |q| q.echo = false }
148
+ @storage.keypair_load pw
149
+ say("All matching systems\n")
150
+ @storage.list_all_systems.sort.each do |system|
151
+ if system.match opts[:search]
152
+ say(" - #{system}")
153
+ end
154
+ end
155
+ return
156
+ end
157
+
158
+ if opts[:list]
159
+ pw = ask("Enter your password:") { |q| q.echo = false }
160
+ @storage.keypair_load pw
161
+ say("All known systems\n")
162
+ @storage.list_all_systems.sort.each do |system|
163
+ say(" - #{system}")
164
+ end
165
+ return
166
+ end
167
+ rescue PWKeep::Exception => e
168
+ PWKeep::logger.error e.message.colorize(:red)
169
+ rescue OpenSSL::PKey::RSAError => e
170
+ PWKeep::logger.error "Cannot load private key".colorize(:red)
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,177 @@
1
+ require 'securerandom'
2
+ require 'base64'
3
+ require 'json'
4
+ require 'hashr'
5
+
6
+ module PWKeep
7
+
8
+ class Storage
9
+ def path
10
+ @options[:path]
11
+ end
12
+
13
+ def initialize(options)
14
+ @options = options
15
+
16
+ @options[:cipher] ||= 'AES-256-CTR'
17
+ @options[:keysize] ||= 2048
18
+ @options[:iterations] ||= 2000
19
+ @options[:digest] ||= 'sha512'
20
+
21
+ unless @options[:path].class == Pathname
22
+ @options[:path] = Pathname.new(@options[:path].to_s).expand_path
23
+ end
24
+
25
+ if path.exist?
26
+ @lockfile = Lockfile.new path.join(".lock").to_s, :retries => 0
27
+ @lockfile.lock
28
+ ObjectSpace.define_finalizer(self, proc { @lockfile.unlock })
29
+ end
30
+ end
31
+
32
+ def create
33
+ return if path.exist?
34
+ path.mkdir
35
+ @lockfile = Lockfile.new path.join(".lock").to_s, :retries => 0
36
+ @lockfile.lock
37
+ ObjectSpace.define_finalizer(self, proc { @lockfile.unlock })
38
+ end
39
+
40
+ def keypair_create(password)
41
+ # ensure it does not exist
42
+ @key = OpenSSL::PKey::RSA.new @options[:keysize]
43
+ cipher = OpenSSL::Cipher.new @options[:keycipher]
44
+
45
+ path.join('private.pem').open 'w' do |io| io.write @key.export(cipher, password) end
46
+ end
47
+
48
+ def keypair_load(password)
49
+ key_pem = path.join('private.pem').read
50
+ @key = OpenSSL::PKey::RSA.new key_pem, password
51
+ end
52
+
53
+ def master_key_load
54
+ unless @key
55
+ raise PWKeep::Exception, "RSA private key required"
56
+ end
57
+
58
+ # load the key
59
+ @master_key = @key.private_decrypt(path.join('master.key').open('rb') { |io| io.read },4)
60
+ end
61
+
62
+ def system_to_hash(system)
63
+ d = Digest.const_get(@options[:digest].upcase).new
64
+
65
+ system_h = system.downcase
66
+ (0..@options[:iterations]).each do
67
+ system_h = d.update(system_h).digest
68
+ d.reset
69
+ end
70
+ "system-#{Base64.urlsafe_encode64(system_h)}"
71
+ end
72
+
73
+ def decrypt_system(file)
74
+ unless @key
75
+ raise PWKeep::Exception, "Private key required"
76
+ end
77
+ # found it, decrypt and load json
78
+ # the file contains crypto name, iv len, iv, data
79
+ header = nil
80
+ data = nil
81
+ file.open('rb') { |io|
82
+ header = io.read @options[:keysize]/8
83
+ data = io.read
84
+ }
85
+
86
+ # header
87
+ cipher = @key.private_decrypt(header,4).unpack('Z*')[0]
88
+ cipher = OpenSSL::Cipher.new cipher
89
+ # re-unpack now that we know the size of the rest of the fields...
90
+ header = @key.private_decrypt(header,4).unpack("Z*a#{cipher.iv_len}a#{cipher.key_len}")
91
+
92
+ cipher.decrypt
93
+ cipher.iv = header[1]
94
+ cipher.key = header[2]
95
+
96
+ # perform decrypt
97
+ cipher.update(data) + cipher.final
98
+ end
99
+
100
+ def encrypt_system(file, data)
101
+ unless @key
102
+ raise PWKeep::Exception, "Private key required"
103
+ end
104
+
105
+ # encrypt data
106
+ cipher = OpenSSL::Cipher::Cipher.new @options[:cipher]
107
+ cipher.encrypt
108
+
109
+ # use one time key and iv
110
+ iv = cipher.random_iv
111
+ key = cipher.random_key
112
+
113
+ header = [cipher.name, iv, key].pack("Z*a#{cipher.iv_len}a#{cipher.key_len}")
114
+ blob = cipher.update(data) + cipher.final
115
+
116
+ # store system name to make search work
117
+ file.open('wb') do |io|
118
+ io.write @key.public_encrypt header, 4
119
+ io.write blob
120
+ end
121
+
122
+ true
123
+ end
124
+
125
+ def load_system(system)
126
+ unless @key
127
+ raise PWKeep::Exception, "Private key required"
128
+ end
129
+
130
+ system_h = system_to_hash(system)
131
+ raise "Cannot find #{system}" unless path.join(system_h).exist?
132
+
133
+ data = decrypt_system(path.join(system_h))
134
+
135
+ unless data[0] == "{" and data[-1] == "}"
136
+ raise PWKeep::Exception, "Corrupted data file"
137
+ end
138
+
139
+ JSON.load(data).deep_symbolize_keys
140
+ end
141
+
142
+ def save_system(system, data)
143
+ unless @key
144
+ raise PWKeep::Exception, "Private key required"
145
+ end
146
+
147
+ # write system
148
+ system_h = system_to_hash(system)
149
+
150
+ data = { :system => system, :data => data, :stored_at => Time.now }
151
+ encrypt_system(path.join(system_h), data)
152
+ end
153
+
154
+ def valid?
155
+ path.join('private.pem').exist?
156
+ end
157
+
158
+ def delete(system)
159
+ data = load_system(system)
160
+ unless data[:system] == system
161
+ raise "System not found"
162
+ end
163
+
164
+ path.join(system_to_hash(system)).delete!
165
+ end
166
+
167
+ def list_all_systems
168
+ systems = []
169
+ path.entries.each do |s|
170
+ next unless s.fnmatch? "system-*"
171
+ systems << JSON.load(decrypt_system(path.join(s)))["system"]
172
+ end
173
+ systems
174
+ end
175
+ end
176
+
177
+ end
data/pwkeep.gemspec ADDED
@@ -0,0 +1,22 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'pwkeep'
3
+ s.version = '0.0.1'
4
+ s.platform = Gem::Platform::RUBY
5
+ s.authors = [ 'Aki Tuomi']
6
+ s.email = %w( aki.tuomi@g-works.fi )
7
+ s.summary = 'Simple password storage gem'
8
+ s.description = 'A simple password storage utility'
9
+ s.rubyforge_project = s.name
10
+ s.files = `git ls-files`.split("\n")
11
+ s.executables = %w( pwkeep )
12
+ s.require_path = 'lib'
13
+ s.license = 'MIT'
14
+ s.add_dependency 'bundler'
15
+ s.add_dependency 'colorize'
16
+ s.add_dependency 'highline'
17
+ s.add_dependency 'trollop'
18
+ s.add_dependency 'lockfile'
19
+ s.add_dependency 'hashr'
20
+ s.add_dependency 'ruco'
21
+ s.add_dependency 'keepass-password-generator'
22
+ end
metadata ADDED
@@ -0,0 +1,190 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pwkeep
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Aki Tuomi
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2014-01-07 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: bundler
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: colorize
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: highline
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ - !ruby/object:Gem::Dependency
63
+ name: trollop
64
+ requirement: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ! '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ type: :runtime
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ - !ruby/object:Gem::Dependency
79
+ name: lockfile
80
+ requirement: !ruby/object:Gem::Requirement
81
+ none: false
82
+ requirements:
83
+ - - ! '>='
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ type: :runtime
87
+ prerelease: false
88
+ version_requirements: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ! '>='
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ - !ruby/object:Gem::Dependency
95
+ name: hashr
96
+ requirement: !ruby/object:Gem::Requirement
97
+ none: false
98
+ requirements:
99
+ - - ! '>='
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ type: :runtime
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ none: false
106
+ requirements:
107
+ - - ! '>='
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: ruco
112
+ requirement: !ruby/object:Gem::Requirement
113
+ none: false
114
+ requirements:
115
+ - - ! '>='
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ none: false
122
+ requirements:
123
+ - - ! '>='
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ - !ruby/object:Gem::Dependency
127
+ name: keepass-password-generator
128
+ requirement: !ruby/object:Gem::Requirement
129
+ none: false
130
+ requirements:
131
+ - - ! '>='
132
+ - !ruby/object:Gem::Version
133
+ version: '0'
134
+ type: :runtime
135
+ prerelease: false
136
+ version_requirements: !ruby/object:Gem::Requirement
137
+ none: false
138
+ requirements:
139
+ - - ! '>='
140
+ - !ruby/object:Gem::Version
141
+ version: '0'
142
+ description: A simple password storage utility
143
+ email:
144
+ - aki.tuomi@g-works.fi
145
+ executables:
146
+ - pwkeep
147
+ extensions: []
148
+ extra_rdoc_files: []
149
+ files:
150
+ - .gitignore
151
+ - Gemfile
152
+ - Gemfile.lock
153
+ - LICENSE
154
+ - README.md
155
+ - Rakefile
156
+ - bin/pwkeep
157
+ - lib/pwkeep.rb
158
+ - lib/pwkeep/config.rb
159
+ - lib/pwkeep/editor.rb
160
+ - lib/pwkeep/generator.rb
161
+ - lib/pwkeep/main.rb
162
+ - lib/pwkeep/storage.rb
163
+ - pwkeep.gemspec
164
+ homepage:
165
+ licenses:
166
+ - MIT
167
+ post_install_message:
168
+ rdoc_options: []
169
+ require_paths:
170
+ - lib
171
+ required_ruby_version: !ruby/object:Gem::Requirement
172
+ none: false
173
+ requirements:
174
+ - - ! '>='
175
+ - !ruby/object:Gem::Version
176
+ version: '0'
177
+ required_rubygems_version: !ruby/object:Gem::Requirement
178
+ none: false
179
+ requirements:
180
+ - - ! '>='
181
+ - !ruby/object:Gem::Version
182
+ version: '0'
183
+ requirements: []
184
+ rubyforge_project: pwkeep
185
+ rubygems_version: 1.8.23
186
+ signing_key:
187
+ specification_version: 3
188
+ summary: Simple password storage gem
189
+ test_files: []
190
+ has_rdoc: