pwkeep 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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: