3 # password store management tool
5 # Copyright (c) 2008, 2009, 2011, 2013 Peter Palfrader <peter@palfrader.org>
6 # Copyright (c) 2014 Fastly
8 # Permission is hereby granted, free of charge, to any person obtaining
9 # a copy of this software and associated documentation files (the
10 # "Software"), to deal in the Software without restriction, including
11 # without limitation the rights to use, copy, modify, merge, publish,
12 # distribute, sublicense, and/or sell copies of the Software, and to
13 # permit persons to whom the Software is furnished to do so, subject to
14 # the following conditions:
16 # The above copyright notice and this permission notice shall be
17 # included in all copies or substantial portions of the Software.
19 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
20 # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
21 # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
22 # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
23 # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
24 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
25 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
32 Thread.abort_on_exception = true
34 GNUPG = "/usr/bin/gpg"
35 GROUP_PATTERN = "@[a-zA-Z0-9-]+"
36 USER_PATTERN = "[a-zA-Z0-9:-]+"
37 $program_name = File.basename($0, '.*')
38 CONFIG_FILE = ENV['HOME']+ "/.pws.yaml"
40 $editor = ENV['EDITOR']
42 %w{/usr/bin/sensible-editor /usr/bin/editor /usr/bin/vi}.each do |editor|
43 if FileTest.executable?(editor)
53 @@keyid_fpr_mapping = {}
56 def GnuPG.extra_args=(val)
60 def GnuPG.readwrite3(intxt, infd, stdoutfd, stderrfd, statusfd=nil)
61 outtxt, stderrtxt, statustxt = ''
62 thread_in = Thread.new {
66 thread_out = Thread.new {
67 outtxt = stdoutfd.read
70 thread_err = Thread.new {
71 errtxt = stderrfd.read
74 thread_status = Thread.new {
75 statustxt = statusfd.read
82 thread_status.join if thread_status
84 return outtxt, stderrtxt, statustxt
87 def GnuPG.open3call(cmd, intxt, args, require_success = false, do_status=true)
91 statR, statW = IO.pipe if do_status
97 statR.close if do_status
103 exec(cmd, "--status-fd=#{statW.fileno}", *@@extra_args, *args)
107 rescue Exception => e
108 outW.puts("[PWSEXECERROR]: #{e}")
111 raise ("Calling gnupg failed")
118 (outtxt, stderrtxt, statustxt) = readwrite3(intxt, inW, outR, errR, statR);
120 (outtxt, stderrtxt) = readwrite3(intxt, inW, outR, errR);
122 wpid, status = Process.waitpid2 pid
123 throw "Unexpected pid: #{pid} vs #{wpid}" unless pid == wpid
124 throw "Process has not exited!?" unless status.exited?
125 throw "#{cmd} call did not exit sucessfully" if (require_success and status.exitstatus != 0)
126 if m=/^\[PWSEXECERROR\]: (.*)/.match(outtxt) then
127 STDERR.puts "Could not run GnuPG: #{m[1]}"
131 return outtxt, stderrtxt, statustxt, status.exitstatus
133 return outtxt, stderrtxt, status.exitstatus
137 def GnuPG.gpgcall(intxt, args, require_success = false)
138 return open3call(GNUPG, intxt, args, require_success)
141 def GnuPG.init_keys()
143 (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', %w{--fast-list-mode --with-colons --with-fingerprint --list-secret-keys}, true)
146 outtxt.split("\n").each do |line|
147 parts = line.split(':')
148 if (parts[0] == "ssb" or parts[0] == "sec")
149 @@my_keys.push parts[4]
150 elsif (parts[0] == "fpr")
151 @@my_fprs.push parts[9]
155 # This is for my private keys, so we can tell if a file is encrypted to us
156 def GnuPG.get_my_keys()
160 # And this is for my private keys also, so we can tell if we are encrypting to ourselves
161 def GnuPG.get_my_fprs()
166 # This maps public keyids to fingerprints, so we can figure
167 # out if a file that is encrypted to a bunch of keys is
168 # encrypted to the fingerprints it should be encrypted to
169 def GnuPG.get_fpr_from_keyid(keyid)
170 fpr = @@keyid_fpr_mapping[keyid]
171 # this can be null, if we tried to find the fpr but failed to find the key in our keyring
173 STDERR.puts "Warning: No key found for keyid #{keyid}"
177 def GnuPG.get_fprs_from_keyids(keyids)
178 learn_fingerprints_from_keyids(keyids)
179 return keyids.collect{ |k| get_fpr_from_keyid(k) or "unknown" }
182 # this is to load the keys we will soon be asking about into
183 # our keyid-fpr-mapping hash
184 def GnuPG.learn_fingerprints_from_keyids(keyids)
185 need_to_learn = keyids.reject{ |k| @@keyid_fpr_mapping.has_key?(k) }
186 if need_to_learn.size > 0
187 # we can't use --fast-list-mode here because GnuPG is broken
188 # and does not show elmo's fingerprint in a call like
189 # gpg --with-colons --fast-list-mode --with-fingerprint --list-key D7C3F131AB2A91F5
190 args = %w{--with-colons --with-fingerprint --list-keys}
191 args.push "--keyring=./.keyring" if FileTest.exists?(".keyring")
192 args.concat need_to_learn
193 (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', args, true)
197 outtxt.split("\n").each do |line|
198 parts = line.split(':')
199 if (parts[0] == "pub")
201 elsif (parts[0] == "fpr")
203 @@keyid_fpr_mapping[pub] = fpr
204 elsif (parts[0] == "sub")
205 @@keyid_fpr_mapping[parts[4]] = fpr
209 need_to_learn.reject{ |k| @@keyid_fpr_mapping.has_key?(k) }.each { |k| @@keyid_fpr_mapping[k] = nil }
213 def read_input(query, default_yes=true)
221 print "#{query} #{append} "
223 i = STDIN.readline.chomp.downcase
238 def initialize(dirname=".", trusted_users=nil)
241 @trusted_users = load_trusted_users(trusted_users)
242 elsif FileTest.exists?(CONFIG_FILE)
245 yaml = YAML::load_file(CONFIG_FILE)
246 yaml["trusted_users"].each do |k,v|
247 t[File.expand_path(k)] = v
249 @trusted_users = t[File.expand_path(dirname)]
250 if @trusted_users.nil?
251 raise ("Could not find #{File.expand_path(dirname)} in configuration file #{CONFIG_FILE}")
253 rescue Psych::SyntaxError, ArgumentError => e
254 raise("Could not parse YAML: #{e.message}")
257 @trusted_users = load_trusted_users(ENV['HOME']+'/.pws-trusted-users')
263 def load_trusted_users(trusted_users_file)
265 f = File.open(trusted_users_file)
266 rescue Exception => e
271 f.readlines.each do |line|
284 args.push "--keyring=./.keyring" if FileTest.exists?(".keyring")
285 (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, args)
288 statustxt.split("\n").each do |line|
289 if m = /^\[GNUPG:\] GOODSIG/.match(line)
291 elsif m = /^\[GNUPG:\] VALIDSIG \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ ([0-9A-F]+)/.match(line)
297 STDERR.puts ".users file is not signed properly. GnuPG said on stdout:"
299 STDERR.puts "and on stderr:"
300 STDERR.puts stderrtxt
301 STDERR.puts "and via statusfd:"
302 STDERR.puts statustxt
306 if not @trusted_users.include?(validsig)
307 raise ".users file is signed by #{validsig} which is not in #{@trusted_users}"
311 raise "gpg verify failed for .users file"
319 f = File.open(File.join(@dirname, '.users'))
320 rescue Exception => e
327 users = verify(users)
333 users.split("\n").each do |line|
337 if (m = /^(#{USER_PATTERN})\s*=\s*([0-9A-Fa-f]{40})\s*$/.match line)
340 if @users.has_key?(user)
341 STDERR.puts "User #{user} redefined at line #{lno}!"
345 elsif (m = /^(#{GROUP_PATTERN})\s*=\s*(.*)$/.match line)
348 if @groups.has_key?(group)
349 STDERR.puts "Group #{group} redefined at line #{lno}!"
352 members = members.split(/[\t ,]+/)
353 @groups[group] = { "members" => members }
359 return (name =~ /^@/)
361 def check_exists(x, whence, fatal=true)
364 ok=false unless (@groups.has_key?(x))
366 ok=false unless @users.has_key?(x)
369 STDERR.puts( (fatal ? "Error: " : "Warning: ") + "#{whence} contains unknown member #{x}")
375 @groups.each_pair do |groupname, group|
376 group['members'].each do |member|
377 check_exists(member, "Group #{groupname}")
379 group['members_to_do'] = group['members'].clone
385 @groups.each_pair do |groupname, group|
386 group['keys'] = [] unless group['keys']
388 still_contains_groups = false
389 group['members_to_do'].clone.each do |member|
391 if @groups[member]['members_to_do'].size == 0
392 group['keys'].concat @groups[member]['keys']
393 group['members_to_do'].delete(member)
396 still_contains_groups = true
399 group['keys'].push @users[member]
400 group['members_to_do'].delete(member)
404 all_expanded = false if still_contains_groups
406 break if all_expanded
408 cyclic_groups = @groups.keys.reject{|name| @groups[name]['members_to_do'].size == 0}.join(", ")
409 STDERR.puts "Cyclic group memberships in #{cyclic_groups}?"
415 def expand_targets(targets)
419 unless check_exists(t, "access line", false)
424 fprs.concat @groups[t]['keys']
438 attr_reader :accessible, :encrypted, :readable, :readers
440 def EncryptedData.determine_readable(readers)
441 GnuPG.get_my_keys.each do |keyid|
442 return true if readers.include?(keyid)
447 def EncryptedData.list_readers(statustxt)
449 statustxt.split("\n").each do |line|
450 m = /^\[GNUPG:\] ENC_TO ([0-9A-F]+)/.match line
457 def EncryptedData.targets(text)
458 text.split("\n").each do |line|
459 if /^(#|---)/.match line
462 m = /^access: "?((?:(?:#{GROUP_PATTERN}|#{USER_PATTERN}),?\s*)+)"?/.match line
464 return m[1].strip.split(/[\t ,]+/)
469 def initialize(encrypted_content, label)
470 @ignore_decrypt_errors = false
473 @encrypted_content = encrypted_content
474 (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall(@encrypted_content, %w{--with-colons --no-options --no-default-keyring --secret-keyring=/dev/null --keyring=/dev/null})
475 @encrypted = !(statustxt =~ /\[GNUPG:\] NODATA/)
477 @readers = EncryptedData.list_readers(statustxt)
478 @readable = EncryptedData.determine_readable(@readers)
483 (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(@encrypted_content, %w{--decrypt})
484 if !@ignore_decrypt_errors and exitstatus != 0
485 proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when decrypting #{@label}. Proceed?", false)
486 exit(0) unless proceed
487 elsif !@ignore_decrypt_errors and outtxt.length == 0
488 proceed = read_input("Warning: #{@label} decrypted to an empty file. Proceed?")
489 exit(0) unless proceed
495 def encrypt(content, recipients)
496 args = recipients.collect{ |r| "--recipient=#{r}"}
497 args.push "--trust-model=always"
498 args.push "--keyring=./.keyring" if FileTest.exists?(".keyring")
500 args.push "--encrypt"
501 (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, args)
504 statustxt.split("\n").each do |line|
505 m = /^\[GNUPG:\] INV_RECP \S+ ([0-9A-F]+)/.match line
510 again = read_input("Warning: the following recipients are invalid: #{invalid.join(", ")}. Try again (or proceed)?")
511 return false if again
513 if outtxt.length == 0
514 tryagain = read_input("Error: #{@label} encrypted to an empty file. Edit again (or exit)?")
515 return false if tryagain
519 proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when encrypting #{@label}. Said:\n#{stderrtxt}\n#{statustxt}\n\nProceed (or try again)?")
520 return false unless proceed
527 def determine_encryption_targets(content)
528 targets = EncryptedData.targets(content)
530 tryagain = read_input("Warning: Did not find targets to encrypt to in header. Try again (or exit)?", true)
531 return false if tryagain
535 ok, expanded = @groupconfig.expand_targets(targets)
536 if (expanded.size == 0)
537 tryagain = read_input("Errors in access header. Edit again (or exit)?", true)
538 return false if tryagain
541 tryagain = read_input("Warnings in access header. Edit again (or continue)?", true)
542 return false if tryagain
546 GnuPG.get_my_fprs.each do |fpr|
547 if expanded.include?(fpr)
553 tryagain = read_input("File is not being encrypted to you. Edit again (or continue)?", true)
554 return false if tryagain
557 return true, expanded
562 class EncryptedFile < EncryptedData
563 def initialize(filename, new=false, trusted_file=nil)
564 @groupconfig = GroupConfig.new(dirname=File.dirname(filename), trusted_users=trusted_file)
571 unless FileTest.readable?(filename)
579 encrypted_content = File.read(filename)
580 super(encrypted_content, filename)
583 def write_back(content, targets)
584 ok, encrypted = encrypt(content, targets)
585 return false unless ok
587 File.open(@filename,"w").write(encrypted)
593 def help(parser, code=0, io=STDOUT)
594 io.puts "Usage: #{$program_name} ls [<directory> ...]"
595 io.puts parser.summarize
596 io.puts "Lists the contents of the given directory/directories, or the current"
597 io.puts "directory if none is given. For each file show whether it is PGP-encrypted"
598 io.puts "file, and if yes whether we can read it."
604 dir = Dir.open(dirname)
605 rescue Exception => e
610 Dir.chdir(dirname) do
611 unless FileTest.exists?(".users")
612 STDERR.puts "The .users file does not exists here. This is not a password store, is it?"
615 dir.sort.each do |filename|
616 next if (filename =~ /^\./) and not (@all >= 3)
617 stat = File::Stat.new(filename)
619 puts "(sym) #{filename}" if (@all >= 2)
620 elsif stat.directory?
621 puts "(dir) #{filename}" if (@all >= 2)
623 puts "(other) #{filename}" if (@all >= 2)
625 f = EncryptedFile.new(filename)
627 puts "(!perm) #{filename}"
629 puts "(file) #{filename}" if (@all >= 2)
631 puts "(ok) #{filename}"
633 puts "(locked) #{filename}" if (@all >= 1)
642 ARGV.options do |opts|
643 opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
644 opts.on_tail("-a", "--all" , "Show all files (use up to 3 times to show even more than all)") { @all = @all+1 }
649 dirs.push('.') unless dirs.size > 0
650 dirs.each { |dir| ls_dir(dir) }
655 def help(parser, code=0, io=STDOUT)
656 io.puts "Usage: #{$program_name} ed <filename>"
657 io.puts parser.summarize
658 io.puts "Decrypts the file, spawns an editor, and encrypts it again"
663 encrypted_file = EncryptedFile.new(filename, @new)
664 if !@new and !encrypted_file.readable && !@force
665 STDERR.puts "#{filename} is probably not readable"
669 encrypted_to = GnuPG.get_fprs_from_keyids(encrypted_file.readers).sort
671 content = encrypted_file.decrypt
672 original_content = content
674 oldsize = content.length
675 tempfile = Tempfile.open('pws')
676 tempfile.puts content
678 system($editor, tempfile.path)
680 throw "Process has not exited!?" unless status.exited?
681 unless status.exitstatus == 0
682 proceed = read_input("Warning: Editor did not exit successfully (exit code #{status.exitstatus}. Proceed?")
683 exit(0) unless proceed
686 # some editors do not write new content in place, but instead
687 # make a new file and more it in the old file's place.
689 reopened = File.open(tempfile.path, "r+")
690 rescue Exception => e
694 content = reopened.read
696 # zero the file, well, both of them.
697 newsize = content.length
698 clearsize = (newsize > oldsize) ? newsize : oldsize
700 [tempfile, reopened].each do |f|
701 f.seek(0, IO::SEEK_SET)
702 f.print "\0"*clearsize
708 if content.length == 0
709 proceed = read_input("Warning: Content is now empty. Proceed?")
710 exit(0) unless proceed
713 ok, targets = encrypted_file.determine_encryption_targets(content)
716 if (original_content == content)
717 if (targets.sort == encrypted_to)
718 proceed = read_input("Nothing changed. Re-encrypt anyway?", false)
719 exit(0) unless proceed
721 STDERR.puts("Info: Content not changed but re-encrypting anyway because the list of keys changed")
725 success = encrypted_file.write_back(content, targets)
731 ARGV.options do |opts|
732 opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
733 opts.on_tail("-n", "--new" , "Edit new file") { |new| @new=new }
734 opts.on_tail("-f", "--force" , "Spawn an editor even if the file is probably not readable") { |force| @force=force }
737 help(ARGV.options, 1, STDERR) if ARGV.length != 1
738 filename = ARGV.shift
741 if FileTest.exists?(filename)
742 STDERR.puts "#{filename} does exist"
746 if !FileTest.exists?(filename)
747 STDERR.puts "#{filename} does not exist"
749 elsif !FileTest.file?(filename)
750 STDERR.puts "#{filename} is not a regular file"
752 elsif !FileTest.readable?(filename)
753 STDERR.puts "#{filename} is not accessible (unix perms)"
758 dirname = File.dirname(filename)
759 basename = File.basename(filename)
767 def help(parser, code=0, io=STDOUT)
768 io.puts "Usage: #{$program_name} ed <filename>"
769 io.puts parser.summarize
770 io.puts "Reencrypts the file (useful for changed user lists or keys)"
774 $editor = '/bin/true'
780 def help(parser, code=0, io=STDOUT)
781 io.puts "Usage: #{$program_name} get <filename> <query>"
782 io.puts parser.summarize
783 io.puts "Decrypts the file, fetches a key and outputs it to stdout."
784 io.puts "The file must be in YAML format."
785 io.puts "query is a query, formatted like /host/users/root"
789 def get(filename, what)
790 encrypted_file = EncryptedFile.new(filename, @new)
791 if !encrypted_file.readable
792 STDERR.puts "#{filename} is probably not readable"
797 yaml = YAML::load(encrypted_file.decrypt)
798 rescue Psych::SyntaxError, ArgumentError => e
799 STDERR.puts "Could not parse YAML: #{e.message}"
805 a = what.split("/")[1..-1]
808 # q = /, so print top level keys
819 STDERR.puts("No such key or invalid lookup expression")
820 elsif hit.respond_to?(:keys)
831 ARGV.options do |opts|
832 opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
835 help(ARGV.options, 1, STDERR) if ARGV.length != 2
836 filename = ARGV.shift
839 if !FileTest.exists?(filename)
840 STDERR.puts "#{filename} does not exist"
842 elsif !FileTest.file?(filename)
843 STDERR.puts "#{filename} is not a regular file"
845 elsif !FileTest.readable?(filename)
846 STDERR.puts "#{filename} is not accessible (unix perms)"
850 dirname = File.dirname(filename)
851 basename = File.basename(filename)
859 def help(parser, code=0, io=STDOUT)
860 io.puts "Usage: #{$program_name} update-keyring [<keyserver>]"
861 io.puts parser.summarize
862 io.puts "Updates the local .keyring file"
867 ARGV.options do |opts|
868 opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
871 help(ARGV.options, 1, STDERR) if ARGV.length > 1
872 keyserver = ARGV.shift
873 keyserver = 'keys.gnupg.net' unless keyserver
875 groupconfig = GroupConfig.new
876 users = groupconfig.get_users()
877 args = %w{--with-colons --no-options --no-default-keyring --keyring=./.keyring}
879 system('touch', '.keyring')
880 users.each_pair() do |uid, keyid|
882 cmd << "--keyserver=#{keyserver}"
885 puts "Fetching key for #{uid}"
886 (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', cmd)
887 unless (statustxt =~ /^\[GNUPG:\] IMPORT_OK /)
888 STDERR.puts "Warning: did not find IMPORT_OK token in status output"
889 STDERR.puts "gpg exited with exit code #{ecode})"
890 STDERR.puts "Command was gpg #{cmd.join(' ')}"
891 STDERR.puts "stdout was #{outtxt}"
892 STDERR.puts "stderr was #{stderrtxt}"
893 STDERR.puts "statustxt was #{statustxt}"
897 cmd << '--batch' << '--edit' << keyid << 'minimize' << 'save'
898 (outtxt, stderrtxt, statustxt, ecode) = GnuPG.gpgcall('', cmd)
906 def help(parser, code=0, io=STDOUT)
907 io.puts "Usage: #{$program_name} gitdiff <commit> <file>"
908 io.puts parser.summarize
909 io.puts "Shows a diff between the version of <file> in your directory and the"
910 io.puts "version in git at <commit> (or HEAD). Requires that your tree be git"
911 io.puts "managed, obviously."
915 def check_readable(e, label)
916 if !e.readable && !@force
917 STDERR.puts "#{label} is probably not readable."
922 def get_file_at_commit()
923 label = @commit+':'+@filename
924 (encrypted_content, stderrtxt, exitcode) = GnuPG.open3call('git', '', ['show', label], require_success=true, do_status=false)
925 data = EncryptedData.new(encrypted_content, label)
926 check_readable(data, label)
930 def get_file_current()
931 data = EncryptedFile.new(@filename)
932 check_readable(data, @filename)
937 old = get_file_at_commit()
938 cur = get_file_current()
940 t1 = Tempfile.open('pws')
944 t2 = Tempfile.open('pws')
948 system("diff", "-u", t1.path, t2.path)
950 t1.seek(0, IO::SEEK_SET)
951 t1.print "\0"*old.length
955 t2.seek(0, IO::SEEK_SET)
956 t2.print "\0"*cur.length
962 ARGV.options do |opts|
963 opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
964 opts.on_tail("-f", "--force" , "Do it even if the file is probably not readable") { |force| @force=force }
970 @filename = ARGV.shift
971 elsif ARGV.length == 2
973 @filename = ARGV.shift
975 help(ARGV.options, 1, STDERR)
983 def help(code=0, io=STDOUT)
984 io.puts "Usage: #{$program_name} ed"
985 io.puts "Usage: #{$program_name} rc"
986 io.puts " #{$program_name} ls"
987 io.puts " #{$program_name} gitdiff"
988 io.puts " #{$program_name} update-keyring"
989 io.puts " #{$program_name} help"
990 io.puts "Call #{$program_name} <command> --help for additional options/parameters"
997 when 'ls' then Ls.new
998 when 'ed' then Ed.new
999 when 'rc' then Reencrypt.new
1000 when 'gitdiff' then GitDiff.new
1001 when 'get' then Get.new
1002 when 'update-keyring' then KeyringUpdater.new
1009 else help(1, STDERR)
1020 # vim:set shiftwidth=2: