]> err.no Git - pwstore/blob - pws
Drop ypath, since there is no 1.9 implementation, implement the bits we need ourselves
[pwstore] / pws
1 #!/usr/bin/ruby
2
3 # password store management tool
4
5 # Copyright (c) 2008, 2009 Peter Palfrader <peter@palfrader.org>
6 #
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:
14 #
15 # The above copyright notice and this permission notice shall be
16 # included in all copies or substantial portions of the Software.
17 #
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.
25
26 require 'optparse'
27 require 'thread'
28 require 'tempfile'
29
30 require 'yaml'
31 Thread.abort_on_exception = true
32
33 GNUPG = "/usr/bin/gpg"
34 GROUP_PATTERN = "@[a-zA-Z0-9-]+"
35 $program_name = File.basename($0, '.*')
36
37 $editor = ENV['EDITOR']
38 if $editor == nil
39   %w{/usr/bin/sensible-editor /usr/bin/editor /usr/bin/vi}.each do |editor|
40     if FileTest.executable?(editor)
41       $editor = editor
42       break
43     end
44   end
45 end
46
47 class GnuPG
48   @@my_keys = nil
49   @@my_fprs = nil
50   @@keyid_fpr_mapping = {}
51
52   def GnuPG.readwrite3(intxt, infd, stdoutfd, stderrfd, statusfd)
53     outtxt, stderrtxt, statustxt = ''
54     thread_in = Thread.new {
55       infd.print intxt
56       infd.close
57     }
58     thread_out = Thread.new {
59       outtxt = stdoutfd.read
60       stdoutfd.close
61     }
62     thread_err = Thread.new {
63       errtxt = stderrfd.read
64       stderrfd.close
65     }
66     thread_status = Thread.new {
67       statustxt = statusfd.read
68       statusfd.close
69     } if (statusfd)
70
71     thread_in.join
72     thread_out.join
73     thread_err.join
74     thread_status.join if thread_status
75
76     return outtxt, stderrtxt, statustxt
77   end
78
79   def GnuPG.gpgcall(intxt, args, require_success = false)
80     inR, inW = IO.pipe
81     outR, outW = IO.pipe
82     errR, errW = IO.pipe
83     statR, statW = IO.pipe
84
85     pid = Kernel.fork do
86       inW.close
87       outR.close
88       errR.close
89       statR.close
90       STDIN.reopen(inR)
91       STDOUT.reopen(outW)
92       STDERR.reopen(errW)
93       begin
94         exec(GNUPG, "--status-fd=#{statW.fileno}",  *args)
95       rescue Exception => e
96         outW.puts("[PWSEXECERROR]: #{e}")
97         exit(1)
98       end
99       raise ("Calling gnupg failed")
100     end
101     inR.close
102     outW.close
103     errW.close
104     statW.close
105     (outtxt, stderrtxt, statustxt) = readwrite3(intxt, inW, outR, errR, statR);
106     wpid, status = Process.waitpid2 pid
107     throw "Unexpected pid: #{pid} vs #{wpid}" unless pid == wpid
108     throw "Process has not exited!?" unless status.exited?
109     throw "gpg call did not exit sucessfully" if (require_success and status.exitstatus != 0)
110     if m=/^\[PWSEXECERROR\]: (.*)/.match(outtxt) then
111       STDERR.puts "Could not run GnuPG: #{m[1]}"
112       exit(1)
113     end
114     return outtxt, stderrtxt, statustxt, status.exitstatus
115   end
116
117   def GnuPG.init_keys()
118     return if @@my_keys
119     (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', %w{--fast-list-mode --with-colons --with-fingerprint --list-secret-keys}, true)
120     @@my_keys = []
121     @@my_fprs = []
122     outtxt.split("\n").each do |line|
123       parts = line.split(':')
124       if (parts[0] == "ssb" or parts[0] == "sec")
125         @@my_keys.push parts[4]
126       elsif (parts[0] == "fpr")
127         @@my_fprs.push parts[9]
128       end
129     end
130   end
131   # This is for my private keys, so we can tell if a file is encrypted to us
132   def GnuPG.get_my_keys()
133     init_keys
134     @@my_keys
135   end
136   # And this is for my private keys also, so we can tell if we are encrypting to ourselves
137   def GnuPG.get_my_fprs()
138     init_keys
139     @@my_fprs
140   end
141
142   # This maps public keyids to fingerprints, so we can figure
143   # out if a file that is encrypted to a bunch of keys is
144   # encrypted to the fingerprints it should be encrypted to
145   def GnuPG.get_fpr_from_keyid(keyid)
146     fpr = @@keyid_fpr_mapping[keyid]
147     # this can be null, if we tried to find the fpr but failed to find the key in our keyring
148     unless fpr
149       STDERR.puts "Warning: No key found for keyid #{keyid}"
150     end
151     return fpr
152   end
153   def GnuPG.get_fprs_from_keyids(keyids)
154     learn_fingerprints_from_keyids(keyids)
155     return keyids.collect{ |k| get_fpr_from_keyid(k) or "unknown" }
156   end
157
158   # this is to load the keys we will soon be asking about into
159   # our keyid-fpr-mapping hash
160   def GnuPG.learn_fingerprints_from_keyids(keyids)
161     need_to_learn = keyids.reject{ |k| @@keyid_fpr_mapping.has_key?(k) }
162     if need_to_learn.size > 0
163       # we can't use --fast-list-mode here because GnuPG is broken
164       # and does not show elmo's fingerprint in a call like
165       # gpg --with-colons --fast-list-mode --with-fingerprint --list-key D7C3F131AB2A91F5
166       args = %w{--with-colons --with-fingerprint --list-keys}
167       args.concat need_to_learn
168       (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', args, true)
169
170       pub = nil
171       fpr = nil
172       outtxt.split("\n").each do |line|
173         parts = line.split(':')
174         if (parts[0] == "pub")
175           pub = parts[4]
176         elsif (parts[0] == "fpr")
177           fpr = parts[9]
178           @@keyid_fpr_mapping[pub] = fpr
179         elsif (parts[0] == "sub")
180           @@keyid_fpr_mapping[parts[4]] = fpr
181         end
182       end
183     end
184     need_to_learn.reject{ |k| @@keyid_fpr_mapping.has_key?(k) }.each { |k| @@keyid_fpr_mapping[k] = nil }
185   end
186 end
187
188 def read_input(query, default_yes=true)
189   if default_yes
190     append = '[Y/n]'
191   else
192     append = '[y/N]'
193   end
194
195   while true
196     print "#{query} #{append} "
197     begin
198       i = STDIN.readline.chomp.downcase
199     rescue EOFError
200       return default_yes
201     end
202     if i==""
203       return default_yes
204     elsif i=="y"
205       return true
206     elsif i=="n"
207       return false
208     end
209   end
210 end
211
212 class GroupConfig
213   def initialize
214     parse_file
215     expand_groups
216   end
217
218   def verify(content)
219     begin
220       f = File.open(ENV['HOME']+'/.pws-trusted-users')
221     rescue Exception => e
222       STDERR.puts e
223       exit(1)
224     end
225
226     trusted = []
227     f.readlines.each do |line|
228       line.chomp!
229       next if line =~ /^$/
230       next if line =~ /^#/
231
232       trusted.push line
233     end
234
235     args = []
236     args.push "--keyring=./.keyring" if FileTest.exists?(".keyring")
237     (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, args)
238     goodsig = false
239     validsig = nil
240     statustxt.split("\n").each do |line|
241       if m = /^\[GNUPG:\] GOODSIG/.match(line)
242         goodsig = true
243       elsif m = /^\[GNUPG:\] VALIDSIG \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ ([0-9A-F]+)/.match(line)
244         validsig = m[1]
245       end
246     end
247
248     if not goodsig
249       STDERR.puts ".users file is not signed properly.  GnuPG said on stdout:"
250       STDERR.puts outtxt
251       STDERR.puts "and on stderr:"
252       STDERR.puts stderrtxt
253       STDERR.puts "and via statusfd:"
254       STDERR.puts statustxt
255       exit(1)
256     end
257
258     if not trusted.include?(validsig)
259       STDERR.puts ".users file is signed by #{validsig} which is not in ~/.pws-trusted-users"
260       exit(1)
261     end
262
263     if not exitstatus==0
264       STDERR.puts "gpg verify failed for .users file"
265       exit(1)
266     end
267
268     return outtxt
269   end
270
271   def parse_file
272     begin
273       f = File.open('.users')
274     rescue Exception => e
275       STDERR.puts e
276       exit(1)
277     end
278
279     users = f.read
280     f.close
281
282     users = verify(users)
283
284     @users = {}
285     @groups = {}
286
287     lno = 0
288     users.split("\n").each do |line|
289       lno = lno+1
290       next if line =~ /^$/
291       next if line =~ /^#/
292       if (m = /^([a-zA-Z0-9:-]+)\s*=\s*([0-9A-Fa-f]{40})\s*$/.match line)
293         user = m[1]
294         fpr = m[2]
295         if @users.has_key?(user)
296           STDERR.puts "User #{user} redefined at line #{lno}!"
297           exit(1)
298         end
299         @users[user] = fpr
300       elsif (m = /^(#{GROUP_PATTERN})\s*=\s*(.*)$/.match line)
301         group = m[1]
302         members = m[2].strip
303         if @groups.has_key?(group)
304           STDERR.puts "Group #{group} redefined at line #{lno}!"
305           exit(1)
306         end
307         members = members.split(/[\t ,]+/)
308         @groups[group] = { "members" => members }
309       end
310     end
311   end
312
313   def is_group(name)
314     return (name =~ /^@/)
315   end
316   def check_exists(x, whence, fatal=true)
317     ok=true
318     if is_group(x)
319       ok=false unless (@groups.has_key?(x))
320     else
321       ok=false unless @users.has_key?(x)
322     end
323     unless ok
324       STDERR.puts( (fatal ? "Error: " : "Warning: ") + "#{whence} contains unknown member #{x}")
325       exit(1) if fatal
326     end
327     return ok
328   end
329   def expand_groups
330     @groups.each_pair do |groupname, group|
331       group['members'].each do |member|
332         check_exists(member, "Group #{groupname}")
333       end
334       group['members_to_do'] = group['members'].clone
335     end
336
337     while true
338       had_progress = false
339       all_expanded = true
340       @groups.each_pair do |groupname, group|
341         group['keys'] = [] unless group['keys'] 
342
343         still_contains_groups = false
344         group['members_to_do'].clone.each do |member|
345           if is_group(member)
346             if @groups[member]['members_to_do'].size == 0
347               group['keys'].concat @groups[member]['keys']
348               group['members_to_do'].delete(member)
349               had_progress = true
350             else
351               still_contains_groups = true
352             end
353           else
354             group['keys'].push @users[member]
355             group['members_to_do'].delete(member)
356             had_progress = true
357           end
358         end
359         all_expanded = false if still_contains_groups
360       end
361       break if all_expanded
362       unless had_progress
363         cyclic_groups = @groups.keys.reject{|name| @groups[name]['members_to_do'].size == 0}.join(", ")
364         STDERR.puts "Cyclic group memberships in #{cyclic_groups}?"
365         exit(1)
366       end
367     end
368   end
369
370   def expand_targets(targets)
371     fprs = []
372     ok = true
373     targets.each do |t|
374       unless check_exists(t, "access line", false)
375         ok = false
376         next
377       end
378       if is_group(t)
379         fprs.concat @groups[t]['keys']
380       else
381         fprs.push @users[t]
382       end
383     end
384     return ok, fprs.uniq
385   end
386
387   def get_users()
388     return @users
389   end
390 end
391
392 class EncryptedFile
393   attr_reader :accessible, :encrypted, :readable, :readers
394
395   def EncryptedFile.determine_readable(readers)
396     GnuPG.get_my_keys.each do |keyid|
397       return true if readers.include?(keyid)
398     end
399     return false
400   end
401
402   def EncryptedFile.list_readers(statustxt)
403     readers = []
404     statustxt.split("\n").each do |line|
405       m = /^\[GNUPG:\] ENC_TO ([0-9A-F]+)/.match line
406       next unless m
407       readers.push m[1]
408     end
409     return readers
410   end
411
412   def EncryptedFile.targets(text)
413     text.split("\n").each do |line|
414       if /^#/.match line
415         next
416       end
417       m = /^access: "?(#{GROUP_PATTERN})"?/.match line
418       return [] unless m
419       return m[1].strip.split(/[\t ,]+/)
420     end
421   end
422
423
424   def initialize(filename, new=false)
425     @groupconfig = GroupConfig.new
426     @new = new
427     if @new
428       @readers = []
429     end
430
431     @filename = filename
432     unless FileTest.readable?(filename)
433       @accessible = false
434       return
435     end
436     @accessible = true
437     @encrypted_content = File.read(filename)
438     (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall(@encrypted_content, %w{--with-colons --no-options --no-default-keyring --secret-keyring=/dev/null --keyring=/dev/null})
439     @encrypted = !(statustxt =~ /\[GNUPG:\] NODATA/)
440     if @encrypted
441       @readers = EncryptedFile.list_readers(statustxt)
442       @readable = EncryptedFile.determine_readable(@readers)
443     end
444   end
445
446   def decrypt
447     (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(@encrypted_content, %w{--decrypt})
448     if !@new and exitstatus != 0
449       proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when decrypting #{@filename}.  Proceed?", false)
450       exit(0) unless proceed
451     elsif !@new and outtxt.length == 0
452       proceed = read_input("Warning: #{@filename} decrypted to an empty file.  Proceed?")
453       exit(0) unless proceed
454     end
455
456     return outtxt
457   end
458
459   def encrypt(content, recipients)
460     args = recipients.collect{ |r| "--recipient=#{r}"}
461     args.push "--trust-model=always"
462     args.push "--keyring=./.keyring" if FileTest.exists?(".keyring")
463     args.push "--armor"
464     args.push "--encrypt"
465     (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, args)
466
467     invalid = []
468     statustxt.split("\n").each do |line|
469       m = /^\[GNUPG:\] INV_RECP \S+ ([0-9A-F]+)/.match line
470       next unless m
471       invalid.push m[1]
472     end
473     if invalid.size > 0
474       again = read_input("Warning: the following recipients are invalid: #{invalid.join(", ")}. Try again (or proceed)?")
475       return false if again
476     end
477     if outtxt.length == 0
478       tryagain = read_input("Error: #{@filename} encrypted to an empty file.  Edit again (or exit)?")
479       return false if tryagain
480       exit(0)
481     end
482     if exitstatus != 0
483       proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when encrypting #{@filename}. Said:\n#{stderrtxt}\n#{statustxt}\n\nProceed (or try again)?")
484       return false unless proceed
485     end
486
487     return true, outtxt
488   end
489
490
491   def determine_encryption_targets(content)
492     targets = EncryptedFile.targets(content)
493     if targets.size == 0
494       tryagain = read_input("Warning: Did not find targets to encrypt to in header.  Try again (or exit)?", true)
495       return false if tryagain
496       exit(0)
497     end
498
499     ok, expanded = @groupconfig.expand_targets(targets)
500     if (expanded.size == 0)
501       tryagain = read_input("Errors in access header.  Edit again (or exit)?", true)
502       return false if tryagain
503       exit(0)
504     elsif (not ok)
505       tryagain = read_input("Warnings in access header.  Edit again (or continue)?", true)
506       return false if tryagain
507     end
508
509     to_me = false
510     GnuPG.get_my_fprs.each do |fpr|
511       if expanded.include?(fpr)
512         to_me = true
513         break
514       end
515     end
516     unless to_me
517       tryagain = read_input("File is not being encrypted to you.  Edit again (or continue)?", true)
518       return false if tryagain
519     end
520
521     return true, expanded
522   end
523
524   def write_back(content, targets)
525     ok, encrypted = encrypt(content, targets)
526     return false unless ok
527
528     File.open(@filename,"w").write(encrypted)
529     return true
530   end
531 end
532
533 class Ls
534   def help(parser, code=0, io=STDOUT)
535     io.puts "Usage: #{$program_name} ls [<directory> ...]"
536     io.puts parser.summarize
537     io.puts "Lists the contents of the given directory/directories, or the current"
538     io.puts "directory if none is given.  For each file show whether it is PGP-encrypted"
539     io.puts "file, and if yes whether we can read it."
540     exit(code)
541   end
542
543   def ls_dir(dirname)
544     begin
545       dir = Dir.open(dirname)
546     rescue Exception => e
547       STDERR.puts e
548       return
549     end
550     puts "#{dirname}:"
551     Dir.chdir(dirname) do
552       unless FileTest.exists?(".users")
553         STDERR.puts "The .users file does not exists here.  This is not a password store, is it?"
554         exit(1)
555       end
556       dir.sort.each do |filename|
557         next if (filename =~ /^\./) and not (@all >= 3)
558         stat = File::Stat.new(filename)
559         if stat.symlink?
560           puts "(sym)      #{filename}" if (@all >= 2)
561         elsif stat.directory?
562           puts "(dir)      #{filename}" if (@all >= 2)
563         elsif !stat.file?
564           puts "(other)    #{filename}" if (@all >= 2)
565         else
566           f = EncryptedFile.new(filename)
567           if !f.accessible
568             puts "(!perm)    #{filename}"
569           elsif !f.encrypted
570             puts "(file)     #{filename}" if (@all >= 2)
571           elsif f.readable
572             puts "(ok)       #{filename}"
573           else
574             puts "(locked)   #{filename}" if (@all >= 1)
575           end
576         end
577       end
578     end
579   end
580
581   def initialize()
582     @all = 0
583     ARGV.options do |opts|
584       opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
585       opts.on_tail("-a", "--all" , "Show all files (use up to 3 times to show even more than all)") { @all = @all+1 }
586       opts.parse!
587     end
588
589     dirs = ARGV
590     dirs.push('.') unless dirs.size > 0
591     dirs.each { |dir| ls_dir(dir) }
592   end
593 end
594
595 class Ed
596   def help(parser, code=0, io=STDOUT)
597     io.puts "Usage: #{$program_name} ed <filename>"
598     io.puts parser.summarize
599     io.puts "Decrypts the file, spawns an editor, and encrypts it again"
600     exit(code)
601   end
602
603   def edit(filename)
604     encrypted_file = EncryptedFile.new(filename, @new)
605     if !@new and !encrypted_file.readable && !@force
606       STDERR.puts "#{filename} is probably not readable"
607       exit(1)
608     end
609
610     encrypted_to = GnuPG.get_fprs_from_keyids(encrypted_file.readers).sort
611
612     content = encrypted_file.decrypt
613     original_content = content
614     while true
615       oldsize = content.length
616       tempfile = Tempfile.open('pws')
617       tempfile.puts content
618       tempfile.flush
619       system($editor, tempfile.path)
620       status = $?
621       throw "Process has not exited!?" unless status.exited?
622       unless status.exitstatus == 0
623         proceed = read_input("Warning: Editor did not exit successfully (exit code #{status.exitstatus}.  Proceed?")
624         exit(0) unless proceed
625       end
626
627       # some editors do not write new content in place, but instead
628       # make a new file and more it in the old file's place.
629       begin
630         reopened = File.open(tempfile.path, "r+")
631       rescue Exception => e
632         STDERR.puts e
633         exit(1)
634       end
635       content = reopened.read
636
637       # zero the file, well, both of them.
638       newsize = content.length
639       clearsize = (newsize > oldsize) ? newsize : oldsize
640
641       [tempfile, reopened].each do |f|
642         f.seek(0, IO::SEEK_SET)
643         f.print "\0"*clearsize
644         f.fsync
645       end
646       reopened.close
647       tempfile.close(true)
648
649       if content.length == 0
650         proceed = read_input("Warning: Content is now empty.  Proceed?")
651         exit(0) unless proceed
652       end
653
654       ok, targets = encrypted_file.determine_encryption_targets(content)
655       next unless ok
656
657       if (original_content == content)
658         if (targets.sort == encrypted_to)
659           proceed = read_input("Nothing changed.  Re-encrypt anyway?", false)
660           exit(0) unless proceed
661         else
662           STDERR.puts("Info: Content not changed but re-encrypting anyway because the list of keys changed")
663         end
664       end
665
666       success = encrypted_file.write_back(content, targets)
667       break if success
668     end
669   end
670
671   def initialize()
672     ARGV.options do |opts|
673       opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
674       opts.on_tail("-n", "--new" , "Edit new file") { |new| @new=new }
675       opts.on_tail("-f", "--force" , "Spawn an editor even if the file is probably not readable") { |force| @force=force }
676       opts.parse!
677     end
678     help(ARGV.options, 1, STDERR) if ARGV.length != 1
679     filename = ARGV.shift
680
681     if @new
682       if FileTest.exists?(filename)
683         STDERR.puts "#{filename} does exist"
684         exit(1)
685       end
686     else
687       if !FileTest.exists?(filename)
688         STDERR.puts "#{filename} does not exist"
689         exit(1)
690       elsif !FileTest.file?(filename)
691         STDERR.puts "#{filename} is not a regular file"
692         exit(1)
693       elsif !FileTest.readable?(filename)
694         STDERR.puts "#{filename} is not accessible (unix perms)"
695         exit(1)
696       end
697     end
698
699     dirname = File.dirname(filename)
700     basename = File.basename(filename)
701     Dir.chdir(dirname) {
702       edit(basename)
703     }
704   end
705 end
706
707 class Get
708   def help(parser, code=0, io=STDOUT)
709     io.puts "Usage: #{$program_name} get <filename> <query>"
710     io.puts parser.summarize
711     io.puts "Decrypts the file, fetches a key and outputs it to stdout."
712     io.puts "The file must be in YAML format."
713     io.puts "query is a query, formatted like host/users/root"
714     exit(code)
715   end
716
717   def get(filename, what)
718     encrypted_file = EncryptedFile.new(filename, @new)
719     if !encrypted_file.readable
720       STDERR.puts "#{filename} is probably not readable"
721       exit(1)
722     end
723
724     begin
725       yaml = YAML::load(encrypted_file.decrypt)
726     rescue Psych::SyntaxError, ArgumentError => e
727       STDERR.puts "Could not parse YAML: #{e.message}"
728       exit(1)
729     end
730
731     require 'pp'
732
733     a = what.split("/")
734     hit = yaml
735     a.each do |k|
736       hit = hit[k]
737     end
738     if hit.nil?
739       STDERR.puts("No such key or invalid lookup expression")
740     elsif hit.respond_to?(:keys)
741       puts "Keys:"
742       puts hit.keys.join("\n")
743     else
744         puts hit
745     end
746   end
747
748   def initialize()
749     ARGV.options do |opts|
750       opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
751       opts.parse!
752     end
753     help(ARGV.options, 1, STDERR) if ARGV.length != 2
754     filename = ARGV.shift
755     what = ARGV.shift
756
757     if !FileTest.exists?(filename)
758       STDERR.puts "#{filename} does not exist"
759       exit(1)
760     elsif !FileTest.file?(filename)
761       STDERR.puts "#{filename} is not a regular file"
762       exit(1)
763     elsif !FileTest.readable?(filename)
764       STDERR.puts "#{filename} is not accessible (unix perms)"
765       exit(1)
766     end
767
768     dirname = File.dirname(filename)
769     basename = File.basename(filename)
770     Dir.chdir(dirname) {
771       get(basename, what)
772     }
773   end
774 end
775
776 class KeyringUpdater
777   def help(parser, code=0, io=STDOUT)
778     io.puts "Usage: #{$program_name} update-keyring [<keyserver>]"
779     io.puts parser.summarize
780     io.puts "Updates the local .keyring file"
781     exit(code)
782   end
783
784   def initialize()
785     ARGV.options do |opts|
786       opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
787       opts.parse!
788     end
789     help(ARGV.options, 1, STDERR) if ARGV.length > 1
790     keyserver = ARGV.shift
791     keyserver = 'keys.gnupg.net' unless keyserver
792
793     groupconfig = GroupConfig.new
794     users = groupconfig.get_users()
795     args = %w{--with-colons --no-options --no-default-keyring --keyring=./.keyring}
796
797     system('touch', '.keyring')
798     users.each_pair() do |uid, keyid|
799       cmd = args.clone()
800       cmd << "--keyserver=#{keyserver}"
801       cmd << "--recv-keys"
802       cmd << keyid
803       puts "Fetching key for #{uid}"
804       (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', cmd)
805       unless (statustxt =~ /^\[GNUPG:\] IMPORT_OK /)
806         STDERR.puts "Warning: did not find IMPORT_OK token in status output"
807         STDERR.puts "gpg exited with exit code #{ecode})"
808         STDERR.puts "Command was gpg #{cmd.join(' ')}"
809         STDERR.puts "stdout was #{outtxt}"
810         STDERR.puts "stderr was #{stderrtxt}"
811         STDERR.puts "statustxt was #{statustxt}"
812       end
813
814       cmd = args.clone()
815       cmd << '--batch' << '--edit' << keyid << 'minimize' << 'save'
816       (outtxt, stderrtxt, statustxt, ecode) = GnuPG.gpgcall('', cmd)
817     end
818
819
820   end
821 end
822
823 def help(code=0, io=STDOUT)
824   io.puts "Usage: #{$program_name} ed"
825   io.puts "       #{$program_name} ls"
826   io.puts "       #{$program_name} update-keyring"
827   io.puts "       #{$program_name} help"
828   io.puts "Call #{$program_name} <command> --help for additional options/parameters"
829   exit(code)
830 end
831
832
833 def parse_command
834   case ARGV.shift
835     when 'ls' then Ls.new
836     when 'ed' then Ed.new
837     when 'get' then Get.new
838     when 'update-keyring' then KeyringUpdater.new
839     when 'help' then
840       case ARGV.length
841         when 0 then help
842         when 1 then
843           ARGV.push "--help"
844           parse_command
845         else help(1, STDERR)
846       end
847     else
848       help(1, STDERR)
849   end
850 end
851
852 parse_command
853
854 # vim:set shiftwidth=2:
855 # vim:set et:
856 # vim:set ts=2: