pwss 0.5.1 → 0.6.0

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/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