3 # password store management tool
5 # Copyright (c) 2008, 2009 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"
34 GROUP_PATTERN = "@[a-zA-Z0-9-]+"
35 USER_PATTERN = "[a-zA-Z0-9:-]+"
36 $program_name = File.basename($0, '.*')
38 $editor = ENV['EDITOR']
40 %w{/usr/bin/sensible-editor /usr/bin/editor /usr/bin/vi}.each do |editor|
41 if FileTest.executable?(editor)
51 @@keyid_fpr_mapping = {}
53 def GnuPG.readwrite3(intxt, infd, stdoutfd, stderrfd, statusfd)
54 outtxt, stderrtxt, statustxt = ''
55 thread_in = Thread.new {
59 thread_out = Thread.new {
60 outtxt = stdoutfd.read
63 thread_err = Thread.new {
64 errtxt = stderrfd.read
67 thread_status = Thread.new {
68 statustxt = statusfd.read
75 thread_status.join if thread_status
77 return outtxt, stderrtxt, statustxt
80 def GnuPG.gpgcall(intxt, args, require_success = false)
84 statR, statW = IO.pipe
95 exec(GNUPG, "--status-fd=#{statW.fileno}", *args)
97 outW.puts("[PWSEXECERROR]: #{e}")
100 raise ("Calling gnupg failed")
106 (outtxt, stderrtxt, statustxt) = readwrite3(intxt, inW, outR, errR, statR);
107 wpid, status = Process.waitpid2 pid
108 throw "Unexpected pid: #{pid} vs #{wpid}" unless pid == wpid
109 throw "Process has not exited!?" unless status.exited?
110 throw "gpg call did not exit sucessfully" if (require_success and status.exitstatus != 0)
111 if m=/^\[PWSEXECERROR\]: (.*)/.match(outtxt) then
112 STDERR.puts "Could not run GnuPG: #{m[1]}"
115 return outtxt, stderrtxt, statustxt, status.exitstatus
118 def GnuPG.init_keys()
120 (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', %w{--fast-list-mode --with-colons --with-fingerprint --list-secret-keys}, true)
123 outtxt.split("\n").each do |line|
124 parts = line.split(':')
125 if (parts[0] == "ssb" or parts[0] == "sec")
126 @@my_keys.push parts[4]
127 elsif (parts[0] == "fpr")
128 @@my_fprs.push parts[9]
132 # This is for my private keys, so we can tell if a file is encrypted to us
133 def GnuPG.get_my_keys()
137 # And this is for my private keys also, so we can tell if we are encrypting to ourselves
138 def GnuPG.get_my_fprs()
143 # This maps public keyids to fingerprints, so we can figure
144 # out if a file that is encrypted to a bunch of keys is
145 # encrypted to the fingerprints it should be encrypted to
146 def GnuPG.get_fpr_from_keyid(keyid)
147 fpr = @@keyid_fpr_mapping[keyid]
148 # this can be null, if we tried to find the fpr but failed to find the key in our keyring
150 STDERR.puts "Warning: No key found for keyid #{keyid}"
154 def GnuPG.get_fprs_from_keyids(keyids)
155 learn_fingerprints_from_keyids(keyids)
156 return keyids.collect{ |k| get_fpr_from_keyid(k) or "unknown" }
159 # this is to load the keys we will soon be asking about into
160 # our keyid-fpr-mapping hash
161 def GnuPG.learn_fingerprints_from_keyids(keyids)
162 need_to_learn = keyids.reject{ |k| @@keyid_fpr_mapping.has_key?(k) }
163 if need_to_learn.size > 0
164 # we can't use --fast-list-mode here because GnuPG is broken
165 # and does not show elmo's fingerprint in a call like
166 # gpg --with-colons --fast-list-mode --with-fingerprint --list-key D7C3F131AB2A91F5
167 args = %w{--with-colons --with-fingerprint --list-keys}
168 args.push "--keyring=./.keyring" if FileTest.exists?(".keyring")
169 args.concat need_to_learn
170 (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', args, true)
174 outtxt.split("\n").each do |line|
175 parts = line.split(':')
176 if (parts[0] == "pub")
178 elsif (parts[0] == "fpr")
180 @@keyid_fpr_mapping[pub] = fpr
181 elsif (parts[0] == "sub")
182 @@keyid_fpr_mapping[parts[4]] = fpr
186 need_to_learn.reject{ |k| @@keyid_fpr_mapping.has_key?(k) }.each { |k| @@keyid_fpr_mapping[k] = nil }
190 def read_input(query, default_yes=true)
198 print "#{query} #{append} "
200 i = STDIN.readline.chomp.downcase
222 f = File.open(ENV['HOME']+'/.pws-trusted-users')
223 rescue Exception => e
229 f.readlines.each do |line|
238 args.push "--keyring=./.keyring" if FileTest.exists?(".keyring")
239 (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, args)
242 statustxt.split("\n").each do |line|
243 if m = /^\[GNUPG:\] GOODSIG/.match(line)
245 elsif m = /^\[GNUPG:\] VALIDSIG \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ ([0-9A-F]+)/.match(line)
251 STDERR.puts ".users file is not signed properly. GnuPG said on stdout:"
253 STDERR.puts "and on stderr:"
254 STDERR.puts stderrtxt
255 STDERR.puts "and via statusfd:"
256 STDERR.puts statustxt
260 if not trusted.include?(validsig)
261 STDERR.puts ".users file is signed by #{validsig} which is not in ~/.pws-trusted-users"
266 STDERR.puts "gpg verify failed for .users file"
275 f = File.open('.users')
276 rescue Exception => e
284 users = verify(users)
290 users.split("\n").each do |line|
294 if (m = /^(#{USER_PATTERN})\s*=\s*([0-9A-Fa-f]{40})\s*$/.match line)
297 if @users.has_key?(user)
298 STDERR.puts "User #{user} redefined at line #{lno}!"
302 elsif (m = /^(#{GROUP_PATTERN})\s*=\s*(.*)$/.match line)
305 if @groups.has_key?(group)
306 STDERR.puts "Group #{group} redefined at line #{lno}!"
309 members = members.split(/[\t ,]+/)
310 @groups[group] = { "members" => members }
316 return (name =~ /^@/)
318 def check_exists(x, whence, fatal=true)
321 ok=false unless (@groups.has_key?(x))
323 ok=false unless @users.has_key?(x)
326 STDERR.puts( (fatal ? "Error: " : "Warning: ") + "#{whence} contains unknown member #{x}")
332 @groups.each_pair do |groupname, group|
333 group['members'].each do |member|
334 check_exists(member, "Group #{groupname}")
336 group['members_to_do'] = group['members'].clone
342 @groups.each_pair do |groupname, group|
343 group['keys'] = [] unless group['keys']
345 still_contains_groups = false
346 group['members_to_do'].clone.each do |member|
348 if @groups[member]['members_to_do'].size == 0
349 group['keys'].concat @groups[member]['keys']
350 group['members_to_do'].delete(member)
353 still_contains_groups = true
356 group['keys'].push @users[member]
357 group['members_to_do'].delete(member)
361 all_expanded = false if still_contains_groups
363 break if all_expanded
365 cyclic_groups = @groups.keys.reject{|name| @groups[name]['members_to_do'].size == 0}.join(", ")
366 STDERR.puts "Cyclic group memberships in #{cyclic_groups}?"
372 def expand_targets(targets)
376 unless check_exists(t, "access line", false)
381 fprs.concat @groups[t]['keys']
395 attr_reader :accessible, :encrypted, :readable, :readers
397 def EncryptedFile.determine_readable(readers)
398 GnuPG.get_my_keys.each do |keyid|
399 return true if readers.include?(keyid)
404 def EncryptedFile.list_readers(statustxt)
406 statustxt.split("\n").each do |line|
407 m = /^\[GNUPG:\] ENC_TO ([0-9A-F]+)/.match line
414 def EncryptedFile.targets(text)
415 text.split("\n").each do |line|
419 m = /^access: "?((?:(?:#{GROUP_PATTERN}|#{USER_PATTERN}),?\s*)+)"?/.match line
421 return m[1].strip.split(/[\t ,]+/)
426 def initialize(filename, new=false)
427 @groupconfig = GroupConfig.new
434 unless FileTest.readable?(filename)
439 @encrypted_content = File.read(filename)
440 (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall(@encrypted_content, %w{--with-colons --no-options --no-default-keyring --secret-keyring=/dev/null --keyring=/dev/null})
441 @encrypted = !(statustxt =~ /\[GNUPG:\] NODATA/)
443 @readers = EncryptedFile.list_readers(statustxt)
444 @readable = EncryptedFile.determine_readable(@readers)
449 (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(@encrypted_content, %w{--decrypt})
450 if !@new and exitstatus != 0
451 proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when decrypting #{@filename}. Proceed?", false)
452 exit(0) unless proceed
453 elsif !@new and outtxt.length == 0
454 proceed = read_input("Warning: #{@filename} decrypted to an empty file. Proceed?")
455 exit(0) unless proceed
461 def encrypt(content, recipients)
462 args = recipients.collect{ |r| "--recipient=#{r}"}
463 args.push "--trust-model=always"
464 args.push "--keyring=./.keyring" if FileTest.exists?(".keyring")
466 args.push "--encrypt"
467 (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, args)
470 statustxt.split("\n").each do |line|
471 m = /^\[GNUPG:\] INV_RECP \S+ ([0-9A-F]+)/.match line
476 again = read_input("Warning: the following recipients are invalid: #{invalid.join(", ")}. Try again (or proceed)?")
477 return false if again
479 if outtxt.length == 0
480 tryagain = read_input("Error: #{@filename} encrypted to an empty file. Edit again (or exit)?")
481 return false if tryagain
485 proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when encrypting #{@filename}. Said:\n#{stderrtxt}\n#{statustxt}\n\nProceed (or try again)?")
486 return false unless proceed
493 def determine_encryption_targets(content)
494 targets = EncryptedFile.targets(content)
496 tryagain = read_input("Warning: Did not find targets to encrypt to in header. Try again (or exit)?", true)
497 return false if tryagain
501 ok, expanded = @groupconfig.expand_targets(targets)
502 if (expanded.size == 0)
503 tryagain = read_input("Errors in access header. Edit again (or exit)?", true)
504 return false if tryagain
507 tryagain = read_input("Warnings in access header. Edit again (or continue)?", true)
508 return false if tryagain
512 GnuPG.get_my_fprs.each do |fpr|
513 if expanded.include?(fpr)
519 tryagain = read_input("File is not being encrypted to you. Edit again (or continue)?", true)
520 return false if tryagain
523 return true, expanded
526 def write_back(content, targets)
527 ok, encrypted = encrypt(content, targets)
528 return false unless ok
530 File.open(@filename,"w").write(encrypted)
536 def help(parser, code=0, io=STDOUT)
537 io.puts "Usage: #{$program_name} ls [<directory> ...]"
538 io.puts parser.summarize
539 io.puts "Lists the contents of the given directory/directories, or the current"
540 io.puts "directory if none is given. For each file show whether it is PGP-encrypted"
541 io.puts "file, and if yes whether we can read it."
547 dir = Dir.open(dirname)
548 rescue Exception => e
553 Dir.chdir(dirname) do
554 unless FileTest.exists?(".users")
555 STDERR.puts "The .users file does not exists here. This is not a password store, is it?"
558 dir.sort.each do |filename|
559 next if (filename =~ /^\./) and not (@all >= 3)
560 stat = File::Stat.new(filename)
562 puts "(sym) #{filename}" if (@all >= 2)
563 elsif stat.directory?
564 puts "(dir) #{filename}" if (@all >= 2)
566 puts "(other) #{filename}" if (@all >= 2)
568 f = EncryptedFile.new(filename)
570 puts "(!perm) #{filename}"
572 puts "(file) #{filename}" if (@all >= 2)
574 puts "(ok) #{filename}"
576 puts "(locked) #{filename}" if (@all >= 1)
585 ARGV.options do |opts|
586 opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
587 opts.on_tail("-a", "--all" , "Show all files (use up to 3 times to show even more than all)") { @all = @all+1 }
592 dirs.push('.') unless dirs.size > 0
593 dirs.each { |dir| ls_dir(dir) }
598 def help(parser, code=0, io=STDOUT)
599 io.puts "Usage: #{$program_name} ed <filename>"
600 io.puts parser.summarize
601 io.puts "Decrypts the file, spawns an editor, and encrypts it again"
606 encrypted_file = EncryptedFile.new(filename, @new)
607 if !@new and !encrypted_file.readable && !@force
608 STDERR.puts "#{filename} is probably not readable"
612 encrypted_to = GnuPG.get_fprs_from_keyids(encrypted_file.readers).sort
614 content = encrypted_file.decrypt
615 original_content = content
617 oldsize = content.length
618 tempfile = Tempfile.open('pws')
619 tempfile.puts content
621 system($editor, tempfile.path)
623 throw "Process has not exited!?" unless status.exited?
624 unless status.exitstatus == 0
625 proceed = read_input("Warning: Editor did not exit successfully (exit code #{status.exitstatus}. Proceed?")
626 exit(0) unless proceed
629 # some editors do not write new content in place, but instead
630 # make a new file and more it in the old file's place.
632 reopened = File.open(tempfile.path, "r+")
633 rescue Exception => e
637 content = reopened.read
639 # zero the file, well, both of them.
640 newsize = content.length
641 clearsize = (newsize > oldsize) ? newsize : oldsize
643 [tempfile, reopened].each do |f|
644 f.seek(0, IO::SEEK_SET)
645 f.print "\0"*clearsize
651 if content.length == 0
652 proceed = read_input("Warning: Content is now empty. Proceed?")
653 exit(0) unless proceed
656 ok, targets = encrypted_file.determine_encryption_targets(content)
659 if (original_content == content)
660 if (targets.sort == encrypted_to)
661 proceed = read_input("Nothing changed. Re-encrypt anyway?", false)
662 exit(0) unless proceed
664 STDERR.puts("Info: Content not changed but re-encrypting anyway because the list of keys changed")
668 success = encrypted_file.write_back(content, targets)
674 ARGV.options do |opts|
675 opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
676 opts.on_tail("-n", "--new" , "Edit new file") { |new| @new=new }
677 opts.on_tail("-f", "--force" , "Spawn an editor even if the file is probably not readable") { |force| @force=force }
680 help(ARGV.options, 1, STDERR) if ARGV.length != 1
681 filename = ARGV.shift
684 if FileTest.exists?(filename)
685 STDERR.puts "#{filename} does exist"
689 if !FileTest.exists?(filename)
690 STDERR.puts "#{filename} does not exist"
692 elsif !FileTest.file?(filename)
693 STDERR.puts "#{filename} is not a regular file"
695 elsif !FileTest.readable?(filename)
696 STDERR.puts "#{filename} is not accessible (unix perms)"
701 dirname = File.dirname(filename)
702 basename = File.basename(filename)
710 def help(parser, code=0, io=STDOUT)
711 io.puts "Usage: #{$program_name} get <filename> <query>"
712 io.puts parser.summarize
713 io.puts "Decrypts the file, fetches a key and outputs it to stdout."
714 io.puts "The file must be in YAML format."
715 io.puts "query is a query, formatted like /host/users/root"
719 def get(filename, what)
720 encrypted_file = EncryptedFile.new(filename, @new)
721 if !encrypted_file.readable
722 STDERR.puts "#{filename} is probably not readable"
727 yaml = YAML::load(encrypted_file.decrypt)
728 rescue Psych::SyntaxError, ArgumentError => e
729 STDERR.puts "Could not parse YAML: #{e.message}"
735 a = what.split("/")[1..-1]
738 # q = /, so print top level keys
749 STDERR.puts("No such key or invalid lookup expression")
750 elsif hit.respond_to?(:keys)
761 ARGV.options do |opts|
762 opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
765 help(ARGV.options, 1, STDERR) if ARGV.length != 2
766 filename = ARGV.shift
769 if !FileTest.exists?(filename)
770 STDERR.puts "#{filename} does not exist"
772 elsif !FileTest.file?(filename)
773 STDERR.puts "#{filename} is not a regular file"
775 elsif !FileTest.readable?(filename)
776 STDERR.puts "#{filename} is not accessible (unix perms)"
780 dirname = File.dirname(filename)
781 basename = File.basename(filename)
789 def help(parser, code=0, io=STDOUT)
790 io.puts "Usage: #{$program_name} update-keyring [<keyserver>]"
791 io.puts parser.summarize
792 io.puts "Updates the local .keyring file"
797 ARGV.options do |opts|
798 opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
801 help(ARGV.options, 1, STDERR) if ARGV.length > 1
802 keyserver = ARGV.shift
803 keyserver = 'keys.gnupg.net' unless keyserver
805 groupconfig = GroupConfig.new
806 users = groupconfig.get_users()
807 args = %w{--with-colons --no-options --no-default-keyring --keyring=./.keyring}
809 system('touch', '.keyring')
810 users.each_pair() do |uid, keyid|
812 cmd << "--keyserver=#{keyserver}"
815 puts "Fetching key for #{uid}"
816 (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', cmd)
817 unless (statustxt =~ /^\[GNUPG:\] IMPORT_OK /)
818 STDERR.puts "Warning: did not find IMPORT_OK token in status output"
819 STDERR.puts "gpg exited with exit code #{ecode})"
820 STDERR.puts "Command was gpg #{cmd.join(' ')}"
821 STDERR.puts "stdout was #{outtxt}"
822 STDERR.puts "stderr was #{stderrtxt}"
823 STDERR.puts "statustxt was #{statustxt}"
827 cmd << '--batch' << '--edit' << keyid << 'minimize' << 'save'
828 (outtxt, stderrtxt, statustxt, ecode) = GnuPG.gpgcall('', cmd)
835 def help(code=0, io=STDOUT)
836 io.puts "Usage: #{$program_name} ed"
837 io.puts " #{$program_name} ls"
838 io.puts " #{$program_name} update-keyring"
839 io.puts " #{$program_name} help"
840 io.puts "Call #{$program_name} <command> --help for additional options/parameters"
847 when 'ls' then Ls.new
848 when 'ed' then Ed.new
849 when 'get' then Get.new
850 when 'update-keyring' then KeyringUpdater.new
866 # vim:set shiftwidth=2: