3 # password store management tool
5 # Copyright (c) 2008 Peter Palfrader <peter@palfrader.org>
7 # Permission is hereby granted, free of charge, to any person obtaining
8 # a copy of this software and associated documentation files (the
9 # "Software"), to deal in the Software without restriction, including
10 # without limitation the rights to use, copy, modify, merge, publish,
11 # distribute, sublicense, and/or sell copies of the Software, and to
12 # permit persons to whom the Software is furnished to do so, subject to
13 # the following conditions:
15 # The above copyright notice and this permission notice shall be
16 # included in all copies or substantial portions of the Software.
18 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
19 # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
20 # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
21 # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
22 # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
23 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
24 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
31 Thread.abort_on_exception = true
33 GNUPG = "/usr/bin/gpg"
35 $program_name = File.basename($0, '.*')
37 $editor = ENV['EDITOR']
39 %w{/usr/bin/sensible-editor /usr/bin/editor /usr/bin/vi}.each do |editor|
40 if FileTest.executable?(editor)
50 def GnuPG.readwrite3(intxt, infd, stdoutfd, stderrfd, statusfd)
51 outtxt, stderrtxt, statustxt = ''
52 thread_in = Thread.new {
56 thread_out = Thread.new {
57 outtxt = stdoutfd.read
60 thread_err = Thread.new {
61 errtxt = stderrfd.read
64 thread_status = Thread.new {
65 statustxt = statusfd.read
72 thread_status.join if thread_status
74 return outtxt, stderrtxt, statustxt
77 def GnuPG.gpgcall(intxt, args, require_success = false)
81 statR, statW = IO.pipe
91 exec(GNUPG, "--status-fd=#{statW.fileno}", *args)
92 raise ("Calling gnupg failed")
98 (outtxt, stderrtxt, statustxt) = readwrite3(intxt, inW, outR, errR, statR);
99 wpid, status = Process.waitpid2 pid
100 throw "Unexpected pid: #{pid} vs #{wpid}" unless pid == wpid
101 throw "Process has not exited!?" unless status.exited?
102 throw "gpg call did not exit sucessfully" if (require_success and status.exitstatus != 0)
103 return outtxt, stderrtxt, statustxt, status.exitstatus
106 def GnuPG.init_keys()
108 (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', %w{--fast-list-mode --with-colons --with-fingerprint --list-secret-keys}, true)
111 outtxt.split("\n").each do |line|
112 parts = line.split(':')
113 if (parts[0] == "ssb" or parts[0] == "sec")
114 @@my_keys.push parts[4]
115 elsif (parts[0] == "fpr")
116 @@my_fprs.push parts[9]
120 def GnuPG.get_my_keys()
124 def GnuPG.get_my_fprs()
130 def read_input(query, default_yes=true)
138 print "#{query} #{append} "
139 i = STDIN.readline.chomp.downcase
158 f = File.open('.users')
159 rescue Exception => e
168 f.readlines.each do |line|
172 if (m = /^([a-zA-Z0-9-]+)\s*=\s*([0-9A-Fa-f]{40})\s*$/.match line)
175 if @users.has_key?(user)
176 STDERR.puts "User #{user} redefined at line #{lno}!"
180 elsif (m = /^(@[a-zA-Z0-9-]+)\s*=\s*(.*)$/.match line)
183 if @groups.has_key?(group)
184 STDERR.puts "Group #{group} redefined at line #{lno}!"
187 members = members.split(/[\t ,]+/)
188 @groups[group] = { "members" => members }
194 return (name =~ /^@/)
196 def check_exists(x, whence, fatal=true)
199 ok=false unless (@groups.has_key?(x))
201 ok=false unless @users.has_key?(x)
204 STDERR.puts( (fatal ? "Error: " : "Warning: ") + "#{whence} contains unknown member #{x}")
210 @groups.each_pair do |groupname, group|
211 group['members'].each do |member|
212 check_exists(member, "Group #{groupname}")
214 group['members_to_do'] = group['members'].clone
220 @groups.each_pair do |groupname, group|
221 group['keys'] = [] unless group['keys']
223 still_contains_groups = false
224 group['members_to_do'].each do |member|
226 if @groups[member]['members_to_do'].size == 0
227 group['keys'].concat @groups[member]['keys']
228 group['members_to_do'].delete(member)
231 still_contains_groups = true
234 group['keys'].push @users[member]
235 group['members_to_do'].delete(member)
239 all_expanded = false if still_contains_groups
241 break if all_expanded
243 cyclic_groups = @groups.keys.reject{|name| @groups[name]['members_to_do'].size == 0}.join(", ")
244 STDERR.puts "Cyclic group memberships in #{cyclic_groups}?"
250 def expand_targets(targets)
254 unless check_exists(t, "access line", false)
259 fprs.concat @groups[t]['keys']
269 attr_reader :accessible, :encrypted, :readable
271 def EncryptedFile.determine_readable(readers)
272 GnuPG.get_my_keys.each do |keyid|
273 return true if readers.include?(keyid)
278 def EncryptedFile.list_readers(statustxt)
280 statustxt.split("\n").each do |line|
281 m = /^\[GNUPG:\] ENC_TO ([0-9A-F]+)/.match line
288 def EncryptedFile.targets(text)
289 metaline = text.split("\n").first
290 m = /^access: (.*)/.match metaline
292 return m[1].strip.split(/[\t ,]+/)
296 def initialize(filename, new=false)
297 @groupconfig = GroupConfig.new
301 unless FileTest.readable?(filename)
306 @encrypted_content = File.read(filename)
307 (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall(@encrypted_content, %w{--with-colons --no-default-keyring --secret-keyring=/dev/null --keyring=/dev/null})
308 @encrypted = !(statustxt =~ /\[GNUPG:\] NODATA/)
310 @readers = EncryptedFile.list_readers(statustxt)
311 @readable = EncryptedFile.determine_readable(@readers)
316 (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(@encrypted_content, %w{--decrypt})
317 if !@new and exitstatus != 0
318 proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when decrypting #{@filename}. Proceed?")
319 exit(0) unless proceed
320 elsif !@new and outtxt.length == 0
321 proceed = read_input("Warning: #{@filename} decrypted to an empty file. Proceed?")
322 exit(0) unless proceed
328 def encrypt(content, recipients)
329 args = recipients.collect{ |r| "--recipient=#{r}"}
330 args.push "--trust-model=always"
331 args.push "--encrypt"
332 (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, args)
335 proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when encrypting #{@filename}. Proceed (or try again)?")
336 return false unless proceed
337 elsif outtxt.length == 0
338 tryagain = read_input("Error: #{@filename} decrypted to an empty file. Edit again (or exit)?")
339 return false if tryagain
347 def write_back(content)
348 targets = EncryptedFile.targets(content)
350 tryagain = read_input("Warning: Did not find targets to encrypt to in header. Try again (or exit)?", true)
351 return false if tryagain
355 ok, expanded = @groupconfig.expand_targets(targets)
356 if (expanded.size == 0)
357 tryagain = read_input("Errors in access header. Edit again (or exit)?", true)
358 return false if tryagain
361 tryagain = read_input("Warnings in access header. Edit again (or continue)?", true)
362 return false if tryagain
366 GnuPG.get_my_fprs.each do |fpr|
367 if expanded.include?(fpr)
373 tryagain = read_input("File is not being encrypted to you. Edit again (or continue)?", true)
374 return false if tryagain
377 ok, encrypted = encrypt(content, expanded)
378 return false unless ok
380 File.open(@filename,"w").write(encrypted)
386 def help(parser, code=0, io=STDOUT)
387 io.puts "Usage: #{$program_name} ls [<directory> ...]"
388 io.puts parser.summarize
389 io.puts "Lists the contents of the given directory/directories, or the current"
390 io.puts "directory if none is given. For each file show whether it is PGP-encrypted"
391 io.puts "file, and if yes whether we can read it."
397 dir = Dir.open(dirname)
398 rescue Exception => e
403 Dir.chdir(dirname) do
404 dir.sort.each do |filename|
405 next if (filename =~ /^\./) and not (@all >= 3)
406 stat = File::Stat.new(filename)
408 puts "(sym) #{filename}" if (@all >= 2)
409 elsif stat.directory?
410 puts "(dir) #{filename}" if (@all >= 2)
412 puts "(other) #{filename}" if (@all >= 2)
414 f = EncryptedFile.new(filename)
416 puts "(!perm) #{filename}"
418 puts "(file) #{filename}" if (@all >= 2)
420 puts "(ok) #{filename}"
422 puts "(locked) #{filename}" if (@all >= 1)
431 ARGV.options do |opts|
432 opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
433 opts.on_tail("-a", "--all" , "Show all files (use up to 3 times to show even more than all)") { @all = @all+1 }
438 dirs.push('.') unless dirs.size > 0
439 dirs.each { |dir| ls_dir(dir) }
444 def help(parser, code=0, io=STDOUT)
445 io.puts "Usage: #{$program_name} ed <filename>"
446 io.puts parser.summarize
447 io.puts "Decrypts the file, spawns an editor, and encrypts it again"
452 encrypted_file = EncryptedFile.new(filename, @new)
453 if !@new and !encrypted_file.readable && !@force
454 STDERR.puts "#{filename} is probably not readable"
458 content = encrypted_file.decrypt
460 oldsize = content.length
461 tempfile = Tempfile.open('pws')
462 tempfile.puts content
464 system($editor, tempfile.path)
466 throw "Process has not exited!?" unless status.exited?
467 unless status.exitstatus == 0
468 proceed = read_input("Warning: Editor did not exit successfully (exit code #{status.exitstatus}. Proceed?")
469 exit(0) unless proceed
471 tempfile.seek(0, IO::SEEK_SET)
472 content = tempfile.read
475 newsize = content.length
476 tempfile.seek(0, IO::SEEK_SET)
477 clearsize = (newsize > oldsize) ? newsize : oldsize
478 tempfile.print "\0"*clearsize
482 if content.length == 0
483 proceed = read_input("Warning: Content is now empty. Proceed?")
484 exit(0) unless proceed
487 success = encrypted_file.write_back(content)
493 ARGV.options do |opts|
494 opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
495 opts.on_tail("-n", "--new" , "Edit new file") { |@new| }
496 opts.on_tail("-f", "--force" , "Spawn an editor even if the file is probably not readable") { |@force| }
499 help(ARGV.options, 1, STDERR) if ARGV.length != 1
500 filename = ARGV.shift
503 if FileTest.exists?(filename)
504 STDERR.puts "#{filename} does exist"
508 if !FileTest.exists?(filename)
509 STDERR.puts "#{filename} does not exist"
511 elsif !FileTest.file?(filename)
512 STDERR.puts "#{filename} is not a regular file"
514 elsif !FileTest.readable?(filename)
515 STDERR.puts "#{filename} is not accessible (unix perms)"
520 dirname = File.dirname(filename)
521 basename = File.basename(filename)
529 def help(code=0, io=STDOUT)
530 io.puts "Usage: #{$program_name} ed"
531 io.puts " #{$program_name} ls"
532 io.puts " #{$program_name} help"
533 io.puts "Call #{$program_name} <command> --help for additional options/parameters"
557 # vim:set shiftwidth=2: