pwss 0.5.1 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile CHANGED
@@ -1 +1,10 @@
1
1
  require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList['test/**/*_test.rb']
8
+ end
9
+
10
+ task :default => :test
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "pwss"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
@@ -0,0 +1,7 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+
5
+ bundle install
6
+
7
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'pwss'
4
+
5
+ # read-eval-print-step passing all commands and the argument
6
+ Pwss::CommandSemantics.reps Pwss::CommandSyntax.commands, ARGV
@@ -1,127 +1,17 @@
1
- require 'yaml'
2
-
3
- #
4
- # This module reasons at the entries level
5
- # (list of entry)
6
- #
7
-
8
- module Pwss
9
-
10
- # entry_no is the relative id of an entry, specified by the user from the command line
11
- # (useful when the search criteria returns more than one match, in an order which is known
12
- # to the user)
13
- def self.get search_string, entries, entry_no = nil
14
- id = choose_entry search_string, entries, false, entry_no
15
-
16
- entries[id]["password"]
17
- end
18
-
19
-
20
- def self.update search_string, entries, length, alnum
21
- id = choose_entry search_string, entries, true
22
-
23
- password = Cipher.check_or_generate "new password for entry", length, alnum
24
-
25
- entries[id]["password"] = password
26
- entries[id]["updated_at"] = Date.today
27
-
28
- return entries, password
29
- end
30
-
31
- def self.update_field search_string, entries, field
32
- id = choose_entry search_string, entries, true
33
-
34
- field_value = Readline.readline("\nEnter new value for #{field}: ")
35
-
36
- entries[id][field] = field_value
37
- entries[id]["updated_at"] = Date.today
38
- password = entries[id]["password"]
39
-
40
- return entries, password
41
- end
42
-
43
-
44
- def self.destroy search_string, entries
45
- id = choose_entry search_string, entries, true
46
-
47
- entries.delete_at(id) if id != -1
48
- entries
49
- end
50
-
51
-
52
- def self.list entries
53
- index = 0
54
- entries.each do |element|
55
- print_entry index, element
56
- index += 1
57
- end
58
- end
59
-
60
- private
61
-
62
- #
63
- # Let the user select an entry from data
64
- # (data is a YAML string with an array of entries)
65
- #
66
- def self.choose_entry search_string, entries, confirm_even_if_one = false, entry_no = nil
67
- # here we have a nuisance: we want the user to choose one entry
68
- # by relative id (e.g. the third found), but we need to return
69
- # the absolute id (to update the right entry in the safe)
70
- #
71
- # ... so we just keep track of the real ids with an array
72
- # the relative id is the index in the array
73
-
74
- found = Array.new
75
- entries.each_with_index do |entry, index|
76
- if entry["title"].downcase.include?(search_string.downcase)
77
- found << index
78
- end
79
- end
80
-
81
- if found.size == 0 then
82
- printf "No entry matches the search criteria.\n"
83
- exit -1
84
- end
85
-
86
- if entry_no then
87
- # accept entry_no even if there is one entry
88
- id = entry_no
89
- elsif found.size > 1 or confirm_even_if_one then
90
- # print the entry or the entries found together with their relative ids
91
- found.each_with_index do |absolute_index, relative_index|
92
- print_entry relative_index, entries[absolute_index]
93
- end
94
-
95
- printf "\nVarious matches." if found.size > 1
96
-
97
- printf "\nSelect entry by ID (0..#{found.size-1}); -1 or empty string to exit: "
98
- response = Readline.readline
99
- id = response == "" ? -1 : response.to_i
100
- while (id < -1 or id >= found.size)
101
- response = Readline.readline "Select entry by ID (0..#{found.size-1}); -1 or empty string to exit: "
102
- id = response == "" ? -1 : response.to_i
103
- end
104
- if id == -1 then
105
- exit -1
106
- end
107
- else
108
- id = 0
109
- print_entry 0, entries[found[id]]
110
- end
111
-
112
- found[id]
113
- end
114
-
115
- #
116
- # Print entry
117
- #
118
- def self.print_entry id, element
119
- puts "\n---\nENTRY ID: #{id}"
120
- # we need to duplicate, because deletion in place will remove
121
- # passwords from entries (and, frankly, we need them)
122
- new_el = element.dup
123
- new_el.delete("password")
124
- puts new_el.to_yaml
125
- end
126
-
127
- end
1
+ require 'pwss/generators/fields'
2
+ require 'pwss/generators/entry'
3
+ require 'pwss/generators/code'
4
+ require 'pwss/generators/bank_account'
5
+ require 'pwss/generators/credit_card'
6
+ require 'pwss/generators/software_license'
7
+ require 'pwss/generators/sim.rb'
8
+
9
+ require 'pwss/cipher'
10
+ require 'pwss/fileops'
11
+ require 'pwss/safe'
12
+ require 'pwss/password'
13
+
14
+ require 'pwss/cli/command_syntax'
15
+ require 'pwss/cli/command_semantics'
16
+
17
+ require 'pwss/version'
@@ -1,85 +1,22 @@
1
1
  require 'encryptor'
2
-
2
+
3
3
  #
4
4
  # Cipher does encryption and decryption of data
5
5
  # It reasons at the string level (both in input and in output)
6
6
  #
7
- module Cipher
8
- def self.encrypt string, password
9
- Encryptor.encrypt(:value => string, :key => password)
10
- end
11
-
12
- def self.decrypt string, password
13
- begin
14
- Encryptor.decrypt(:value => string, :key => password)
15
- rescue Exception => e
16
- puts "Unable to decrypt. Exiting"
17
- exit 1
7
+ module Pwss
8
+ module Cipher
9
+ def self.encrypt string, password
10
+ Encryptor.encrypt(:value => string, :key => password)
18
11
  end
19
- end
20
-
21
- # Ask for a password fom the command line
22
- def self.ask_password prompt="Enter master password: "
23
- printf prompt
24
- system "stty -echo"
25
- password = $stdin.gets.chomp
26
- system "stty echo"
27
- puts ""
28
- password
29
- end
30
-
31
- # Ask for a password twice and make sure it is entered the same
32
- def self.check_password prompt="master password"
33
- match = false
34
- while ! match
35
- password = ask_password "Enter #{prompt}: "
36
- repeat = ask_password "Repeat #{prompt}: "
37
- match = (password == repeat)
38
12
 
39
- if match == false then
40
- puts "Error! Password do not match. Please enter them again."
13
+ def self.decrypt string, password
14
+ begin
15
+ Encryptor.decrypt(:value => string, :key => password)
16
+ rescue Exception => e
17
+ puts "Unable to decrypt. Exiting"
18
+ exit 1
41
19
  end
42
20
  end
43
- password
44
21
  end
45
-
46
- # Ask for a password (twice) or generate one, if length is greater than 0
47
- def self.check_or_generate prompt, length=0, alnum=false
48
- length > 0 ? generate_password(length, alnum) : check_password(prompt)
49
- end
50
-
51
- #
52
- # make the password available to the clipboard.
53
- #
54
- def self.password_to_clipboard password, counter = 30
55
- old_clipboard = `pbpaste`
56
- system("printf \"%s\" \"#{password}\" | pbcopy")
57
-
58
- begin
59
- if counter <= 0
60
- STDIN.flush
61
- puts "\nPassword available in clipboard: press enter when you are done."
62
- STDIN.getc
63
- else
64
- puts "\nPassword available in clipboard for #{counter} seconds."
65
- sleep(counter)
66
- end
67
- system("printf \"#{old_clipboard}\" | pbcopy")
68
- rescue Exception => e
69
- system("printf \"#{old_clipboard}\" | pbcopy")
70
- puts "Clipboard restored. Exiting."
71
- end
72
- end
73
-
74
- private
75
-
76
- # Generate a random password
77
- # (Adapted from: http://randompasswordsgenerator.net/tutorials/ruby-random-password-generator.html)
78
- def self.generate_password(length=8, alnum=false)
79
- chars = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNOPQRSTUVWXYZ1234567890'
80
- chars += '!@#$%^&*()_+=[]{}<>/~,.;:|' if not alnum
81
- Array.new(length) { chars[rand(chars.length)].chr }.join
82
- end
83
-
84
-
85
22
  end
@@ -0,0 +1,402 @@
1
+ require 'pwss/version'
2
+
3
+ module Pwss
4
+ # what we are supposed to do with each command
5
+ module CommandSemantics
6
+ VERSION = Pwss::VERSION
7
+ # TODO: make the list of entries read from code
8
+ MAN = <<EOS
9
+ NAME
10
+ pwss -- A command-line password manager
11
+
12
+ SYNOPSYS
13
+ pwss command [options] [args]
14
+
15
+ DESCRIPTION
16
+ PWSS is a password manager, in the spirit of pass and pws.
17
+
18
+ Features:
19
+
20
+ * PWSS manages password *files*:
21
+ - A password file can store different entries (password and other
22
+ sensitive information)
23
+ - The user can manage different password files (e.g., work, personal)
24
+
25
+ * Entries in a password file can be of different types. Each type stores
26
+ different information. Use the 'describe' command for more info about
27
+ the available types and their fields.
28
+
29
+ * Password files can be encrypted
30
+
31
+ * Encrypted password files can be decrypted, for instance, to batch process
32
+ entries, to migrate to another tool, or to manually edit entries
33
+
34
+ * Entries are human-readable (and editable), when the password file is not
35
+ encrypted
36
+
37
+ * A console allows to decrypt a file once and perform multiple queries
38
+
39
+ EXAMPLES
40
+ pwss help # get syntax of each command
41
+
42
+ # scenario
43
+ pwss init -f a.enc # generate an encrypted safe a.enc
44
+ pwss add -f a.enc # add an entry (pwss will generate a random 16-char password)
45
+ pwss get -f a.enc my secret account # find an entry
46
+ pwss console -f a.enc # decrypt a.enc and enter the pwss console to operate on a.enc
47
+
48
+ VERSION
49
+ This is version #{VERSION}
50
+
51
+ LICENSE
52
+ MIT
53
+
54
+ SEE ALSO
55
+ pwss man
56
+ pwss help
57
+ https://github.com/avillafiorita/pwss
58
+ EOS
59
+
60
+ # the default filename
61
+ # YOU SHOULDN'T BE USING THESE CONTANSTS. USE `default_filename` INSTEAD
62
+ DEFAULT_BASENAME = File.join(Dir.home, ".pwss.yaml")
63
+ DEFAULT_FILENAME = DEFAULT_BASENAME + ".gpg"
64
+
65
+ # return the default filename
66
+ #
67
+ # this is obtained by looking for plain text or encryped versions
68
+ # of the DEFAULT_BASENAME, with the following priority: .enc,
69
+ # .gpg, plain text.
70
+ #
71
+ # If no file is found (like it might be the case when running the
72
+ # init command), use GPG
73
+ def self.default_filename
74
+ [".enc", ".gpg", ""].each do |ext|
75
+ filename = DEFAULT_BASENAME + ext
76
+ return filename if File.exist?(filename)
77
+ end
78
+ return DEFAULT_FILENAME
79
+ end
80
+
81
+ # return true if the default basename appears with different
82
+ # extensions.
83
+ #
84
+ # for instance: if the DEFAULT_BASENAME appears both with .gpg and .enc
85
+ # (or plain and encrypted).
86
+ #
87
+ # This is potentially a problem, since all operations are
88
+ # performed on a different file from the one the user believes it
89
+ # is operating on.
90
+ def self.ambiguous_default
91
+ [".enc", ".gpg", ""].map { |ext| File.exist?(DEFAULT_BASENAME + ext) }.count(true) > 1
92
+ end
93
+
94
+ # return true if none of the default files exist
95
+ def self.no_default
96
+ [".enc", ".gpg", ""].map { |ext| File.exist?(DEFAULT_BASENAME + ext) }.count(true) == 0
97
+ end
98
+
99
+ # return all the default safes we look for
100
+ def self.all_safes
101
+ [".enc", ".gpg", ""].map { |ext| DEFAULT_BASENAME + ext }
102
+ end
103
+
104
+ # return the existing safes
105
+ def self.existing_safes
106
+ [".enc", ".gpg", ""].map { |ext| DEFAULT_BASENAME + ext }.select { |x| File.exist?(x) }
107
+ end
108
+
109
+ # cache is the content of the file last operated on
110
+ # is it used
111
+ @@cache = nil
112
+
113
+ def self.version opts = nil, argv = []
114
+ puts "pwss version #{VERSION}"
115
+ end
116
+
117
+ def self.man opts = nil, argv = []
118
+ puts MAN
119
+ end
120
+
121
+ def self.help opts = nil, argv = []
122
+ all_commands = Pwss::CommandSyntax.commands
123
+
124
+ if argv != []
125
+ argv.map { |x| puts all_commands[x.to_sym][0] }
126
+ else
127
+ puts "pwss command [options] [args]"
128
+ puts ""
129
+ puts "Available commands:"
130
+ puts ""
131
+ all_commands.keys.each do |key|
132
+ puts " " + all_commands[key][0].banner
133
+ end
134
+ end
135
+ end
136
+
137
+ def self.describe opts = nil, argv = []
138
+ if opts[:type]
139
+ types = [("Pwss::" + opts[:type].capitalize).to_sym]
140
+ else
141
+ types = [Pwss::Entry] + ObjectSpace.each_object(Class).select { |klass| klass < Pwss::Entry }
142
+ end
143
+ types.each do |type|
144
+ t = eval("#{type}.new")
145
+ puts "#{type.to_s.gsub("Pwss::", "").downcase}:\n #{t.fields.join(", ")}\n\n"
146
+ end
147
+ end
148
+
149
+ def self.init opts, argv = []
150
+ filename = opts[:filename] || @@cache.filename || DEFAULT_FILENAME
151
+
152
+ if File.exist?(filename)
153
+ raise "Error: file #{filename} already exists."
154
+ end
155
+
156
+ safe = Pwss::Safe.new filename
157
+ safe.save
158
+
159
+ puts "New safe created in #{filename}"
160
+ end
161
+
162
+ def self.list opts, argv = []
163
+ safe = use_safe opts[:filename]
164
+ clean = opts[:clean]
165
+
166
+ cleaned_entries = safe.prune(["created_at", "updated_at"]).map { |x| Pwss::Fields.to_clean_hash x }
167
+ puts cleaned_entries.to_yaml
168
+ end
169
+
170
+ def self.get opts, argv
171
+ waiting = opts[:wait]
172
+ stdout_opt = opts[:stdout]
173
+ field_name = opts[:field] || "password"
174
+ hide = opts[:hide]
175
+ string = argv.join(" ")
176
+
177
+ safe = use_safe opts[:filename]
178
+ entries_with_idx = safe.match string
179
+ id = Pwss::Safe.choose_entry entries_with_idx
180
+ if id != -1 then
181
+ puts (hide ? safe.get_pruned(id).to_yaml : safe.get(id).to_yaml )
182
+ field_value = safe.get_field id, field_name
183
+ if field_value then
184
+ stdout_opt ? printf("%s", field_value) : Pwss::Password.to_clipboard(field_name, field_value, waiting)
185
+ end
186
+ end
187
+ end
188
+
189
+ def self.add_entry opts, argv
190
+ waiting = opts[:wait]
191
+ type = opts[:type] || "entry"
192
+ strategy = opts[:ask] ? "ask" : (opts[:method] || "random")
193
+ length = opts[:length]
194
+
195
+ safe = use_safe opts[:filename]
196
+
197
+ # the title can be specified in the argument
198
+ arguments = Hash.new
199
+ arguments["title"] = argv.join(" ") if argv != []
200
+ arguments[:strategy] = strategy
201
+ arguments[:length] = length
202
+
203
+ new_entry = eval("Pwss::" + type.capitalize).new
204
+ new_entry.ask arguments
205
+
206
+ puts "Adding entry '#{new_entry.entry["title"]}' of type '#{type}' to #{safe.filename}"
207
+ safe.add new_entry.entry
208
+ safe.save
209
+ puts "Entry added"
210
+
211
+ # make password available in the clipboard, if there is a password to make available
212
+ if new_entry.entry["password"]
213
+ Pwss::Password.to_clipboard "password", new_entry.entry["password"], waiting
214
+ end
215
+ end
216
+
217
+ def self.update opts, argv
218
+ field = (opts.to_hash[:password] or opts.to_hash[:method] or opts.to_hash[:ask]) ? "password" : opts.to_hash[:field]
219
+ strategy = opts.to_hash[:ask] ? "ask" : (opts.to_hash[:method] || "random")
220
+ length = opts.to_hash[:length]
221
+ waiting = opts.to_hash[:wait]
222
+ string = argv.join(" ") # the entry we are looking for
223
+
224
+ if not field then
225
+ raise "Error: please specify a field to update (use --field, -p, or --ask)"
226
+ end
227
+
228
+ safe = use_safe opts[:filename]
229
+
230
+ entries_with_idx = safe.match string
231
+ id = Pwss::Safe.choose_entry entries_with_idx, true
232
+ if id != -1 then
233
+ field_value = Pwss::Fields.ask field, { strategy: strategy, length: length }
234
+ puts "Updating #{field} field of '#{safe.entries[id]["title"]}' in #{safe.filename}"
235
+ safe.update id, field, field_value
236
+ safe.save
237
+ puts "Entry updated"
238
+
239
+ # make the field available in the clipboard, just in case it is needed
240
+ if field == "password"
241
+ Pwss::Password.to_clipboard "password", field_value, waiting
242
+ end
243
+ end
244
+ end
245
+
246
+ def self.destroy opts, argv
247
+ safe = use_safe opts[:filename]
248
+
249
+ string = argv.join(" ")
250
+ entries_with_idx = safe.match string
251
+ id = Pwss::Safe.choose_entry entries_with_idx, true
252
+ if id != -1 then
253
+ safe.destroy id
254
+ safe.save
255
+ end
256
+ end
257
+
258
+ def self.encrypt opts, argv = []
259
+ # filename: use the one passed from the cli or the cached one or .pwss.yaml DEFAULT_*BASE*NAME
260
+ filename = opts[:filename] || (@@cache ? @@cache.filename : DEFAULT_BASENAME)
261
+ encryption = opts[:symmetric] ? :enc : :gpg
262
+
263
+ if not File.exist?(filename)
264
+ raise "Error: file #{filename} does not exist."
265
+ end
266
+
267
+ if Pwss::FileOps.encrypted? filename
268
+ raise "Error: #{filename} ends with '.gpg' or '.enc' (and I assume these files to be encrypted)"
269
+ end
270
+
271
+ if encryption == :enc then
272
+ password = Pwss::Password.ask_password_twice
273
+ if password == "" then
274
+ raise "Error: Please specify a non-empty password."
275
+ end
276
+ else
277
+ password = nil # it will be asked by GPG
278
+ end
279
+
280
+ safe = use_safe filename
281
+ safe.toggle_encryption :password => password, :schema => encryption
282
+ safe.save
283
+ puts "An encrypted copy now lives in #{safe.filename}"
284
+ puts "You might want to check everything is ok and delete the plain file: #{filename}"
285
+ puts "If you do nothing, the next pwss command will run on #{default_filename}"
286
+ end
287
+
288
+ def self.decrypt opts, argv = []
289
+ # filename: passed from options, cached one or, in order, .gpg, .enc, plain (but plain will fail)
290
+ filename = opts[:filename] || (@@cache ? @@cache.filename : default_filename)
291
+
292
+ if not File.exist?(filename)
293
+ raise "Error: file #{filename} does not exist."
294
+ end
295
+
296
+ if not Pwss::FileOps.encrypted? filename
297
+ raise "Error: #{filename} does not end with '.gpg' or '.enc' (and I assume it to be in plain text)"
298
+ end
299
+
300
+ safe = use_safe filename
301
+ safe.toggle_encryption
302
+ safe.save
303
+ puts "A plain text copy now lives in #{safe.filename}"
304
+ puts "You might want to check everything is ok and delete the plain file: #{filename}"
305
+ puts "If you do nothing, the next pwss command will run on #{default_filename}"
306
+ end
307
+
308
+ def self.console opts, argv = []
309
+ all_commands = Pwss::CommandSyntax.commands
310
+ all_commands.delete(:console)
311
+ open opts, argv # open and cache the file
312
+
313
+ i = 0
314
+ while true
315
+ string = Readline.readline('pwss:%03d> ' % i, true)
316
+ string.gsub!(/^pwss /, "") # as a courtesy, remove any leading pwss string
317
+ if string == "exit" or string == "quit" or string == "." then
318
+ exit 0
319
+ end
320
+ reps all_commands, string.split(' ')
321
+ i = i + 1
322
+ end
323
+ end
324
+
325
+ def self.open opts, argv = []
326
+ filename = opts[:filename] || default_filename
327
+ @@cache = load_safe filename
328
+ puts "Loaded #{filename}"
329
+ end
330
+
331
+ def self.default opts, argv = []
332
+ if @@cache
333
+ @@cache.filename
334
+ elsif self.no_default
335
+ puts "No default password file found."
336
+ puts "Use -f if you have a password file stored somewhere else."
337
+ puts "pwss init will create #{default_filename}."
338
+ elsif self.ambiguous_default
339
+ puts "Operating on #{default_filename}."
340
+ puts "Warning: #{existing_safes.join(", ")} exist."
341
+ else
342
+ puts "Operating on #{default_filename}"
343
+ end
344
+ end
345
+
346
+ # read-eval-print step
347
+ def self.reps all_commands, argv
348
+ if argv == []
349
+ Pwss::CommandSemantics.help
350
+ exit 0
351
+ else
352
+ command = argv[0]
353
+ syntax_and_semantics = all_commands[command.to_sym]
354
+
355
+ if syntax_and_semantics
356
+ opts = syntax_and_semantics[0]
357
+ function = syntax_and_semantics[1]
358
+
359
+ begin
360
+ parser = Slop::Parser.new(opts)
361
+ result = parser.parse(argv[1..-1])
362
+ options = result.to_hash
363
+ arguments = result.arguments
364
+
365
+ eval "Pwss::CommandSemantics::#{function}(options, arguments)"
366
+ rescue Slop::Error => e
367
+ puts "pwss: #{e}"
368
+ rescue Exception => e
369
+ puts e
370
+ end
371
+ else
372
+ puts "pwss: '#{command}' is not a pwss command. See 'pwss help'"
373
+ end
374
+ end
375
+ end
376
+
377
+ private
378
+
379
+ # use a specific filename (if specified), try @@cache if -f is not specified, or the default filename
380
+ def self.use_safe filename
381
+ if filename then
382
+ load_safe filename
383
+ elsif @@cache then
384
+ @@cache
385
+ else
386
+ load_safe default_filename
387
+ end
388
+ end
389
+
390
+ # load a password safe
391
+ def self.load_safe filename
392
+ if File.exist?(filename)
393
+ safe = Pwss::Safe.new filename
394
+ safe.load
395
+ safe
396
+ else
397
+ raise "Error: file #{filename} does not exist."
398
+ end
399
+ end
400
+
401
+ end
402
+ end