]> err.no Git - pwstore/blob - pws
Use ./.keyring when learning key fingerprints too
[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 USER_PATTERN = "[a-zA-Z0-9:-]+"
36 $program_name = File.basename($0, '.*')
37
38 $editor = ENV['EDITOR']
39 if $editor == nil
40   %w{/usr/bin/sensible-editor /usr/bin/editor /usr/bin/vi}.each do |editor|
41     if FileTest.executable?(editor)
42       $editor = editor
43       break
44     end
45   end
46 end
47
48 class GnuPG
49   @@my_keys = nil
50   @@my_fprs = nil
51   @@keyid_fpr_mapping = {}
52
53   def GnuPG.readwrite3(intxt, infd, stdoutfd, stderrfd, statusfd)
54     outtxt, stderrtxt, statustxt = ''
55     thread_in = Thread.new {
56       infd.print intxt
57       infd.close
58     }
59     thread_out = Thread.new {
60       outtxt = stdoutfd.read
61       stdoutfd.close
62     }
63     thread_err = Thread.new {
64       errtxt = stderrfd.read
65       stderrfd.close
66     }
67     thread_status = Thread.new {
68       statustxt = statusfd.read
69       statusfd.close
70     } if (statusfd)
71
72     thread_in.join
73     thread_out.join
74     thread_err.join
75     thread_status.join if thread_status
76
77     return outtxt, stderrtxt, statustxt
78   end
79
80   def GnuPG.gpgcall(intxt, args, require_success = false)
81     inR, inW = IO.pipe
82     outR, outW = IO.pipe
83     errR, errW = IO.pipe
84     statR, statW = IO.pipe
85
86     pid = Kernel.fork do
87       inW.close
88       outR.close
89       errR.close
90       statR.close
91       STDIN.reopen(inR)
92       STDOUT.reopen(outW)
93       STDERR.reopen(errW)
94       begin
95         exec(GNUPG, "--status-fd=#{statW.fileno}",  *args)
96       rescue Exception => e
97         outW.puts("[PWSEXECERROR]: #{e}")
98         exit(1)
99       end
100       raise ("Calling gnupg failed")
101     end
102     inR.close
103     outW.close
104     errW.close
105     statW.close
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]}"
113       exit(1)
114     end
115     return outtxt, stderrtxt, statustxt, status.exitstatus
116   end
117
118   def GnuPG.init_keys()
119     return if @@my_keys
120     (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', %w{--fast-list-mode --with-colons --with-fingerprint --list-secret-keys}, true)
121     @@my_keys = []
122     @@my_fprs = []
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]
129       end
130     end
131   end
132   # This is for my private keys, so we can tell if a file is encrypted to us
133   def GnuPG.get_my_keys()
134     init_keys
135     @@my_keys
136   end
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()
139     init_keys
140     @@my_fprs
141   end
142
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
149     unless fpr
150       STDERR.puts "Warning: No key found for keyid #{keyid}"
151     end
152     return fpr
153   end
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" }
157   end
158
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)
171
172       pub = nil
173       fpr = nil
174       outtxt.split("\n").each do |line|
175         parts = line.split(':')
176         if (parts[0] == "pub")
177           pub = parts[4]
178         elsif (parts[0] == "fpr")
179           fpr = parts[9]
180           @@keyid_fpr_mapping[pub] = fpr
181         elsif (parts[0] == "sub")
182           @@keyid_fpr_mapping[parts[4]] = fpr
183         end
184       end
185     end
186     need_to_learn.reject{ |k| @@keyid_fpr_mapping.has_key?(k) }.each { |k| @@keyid_fpr_mapping[k] = nil }
187   end
188 end
189
190 def read_input(query, default_yes=true)
191   if default_yes
192     append = '[Y/n]'
193   else
194     append = '[y/N]'
195   end
196
197   while true
198     print "#{query} #{append} "
199     begin
200       i = STDIN.readline.chomp.downcase
201     rescue EOFError
202       return default_yes
203     end
204     if i==""
205       return default_yes
206     elsif i=="y"
207       return true
208     elsif i=="n"
209       return false
210     end
211   end
212 end
213
214 class GroupConfig
215   def initialize
216     parse_file
217     expand_groups
218   end
219
220   def verify(content)
221     begin
222       f = File.open(ENV['HOME']+'/.pws-trusted-users')
223     rescue Exception => e
224       STDERR.puts e
225       exit(1)
226     end
227
228     trusted = []
229     f.readlines.each do |line|
230       line.chomp!
231       next if line =~ /^$/
232       next if line =~ /^#/
233
234       trusted.push line
235     end
236
237     args = []
238     args.push "--keyring=./.keyring" if FileTest.exists?(".keyring")
239     (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, args)
240     goodsig = false
241     validsig = nil
242     statustxt.split("\n").each do |line|
243       if m = /^\[GNUPG:\] GOODSIG/.match(line)
244         goodsig = true
245       elsif m = /^\[GNUPG:\] VALIDSIG \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ ([0-9A-F]+)/.match(line)
246         validsig = m[1]
247       end
248     end
249
250     if not goodsig
251       STDERR.puts ".users file is not signed properly.  GnuPG said on stdout:"
252       STDERR.puts outtxt
253       STDERR.puts "and on stderr:"
254       STDERR.puts stderrtxt
255       STDERR.puts "and via statusfd:"
256       STDERR.puts statustxt
257       exit(1)
258     end
259
260     if not trusted.include?(validsig)
261       STDERR.puts ".users file is signed by #{validsig} which is not in ~/.pws-trusted-users"
262       exit(1)
263     end
264
265     if not exitstatus==0
266       STDERR.puts "gpg verify failed for .users file"
267       exit(1)
268     end
269
270     return outtxt
271   end
272
273   def parse_file
274     begin
275       f = File.open('.users')
276     rescue Exception => e
277       STDERR.puts e
278       exit(1)
279     end
280
281     users = f.read
282     f.close
283
284     users = verify(users)
285
286     @users = {}
287     @groups = {}
288
289     lno = 0
290     users.split("\n").each do |line|
291       lno = lno+1
292       next if line =~ /^$/
293       next if line =~ /^#/
294       if (m = /^(#{USER_PATTERN})\s*=\s*([0-9A-Fa-f]{40})\s*$/.match line)
295         user = m[1]
296         fpr = m[2]
297         if @users.has_key?(user)
298           STDERR.puts "User #{user} redefined at line #{lno}!"
299           exit(1)
300         end
301         @users[user] = fpr
302       elsif (m = /^(#{GROUP_PATTERN})\s*=\s*(.*)$/.match line)
303         group = m[1]
304         members = m[2].strip
305         if @groups.has_key?(group)
306           STDERR.puts "Group #{group} redefined at line #{lno}!"
307           exit(1)
308         end
309         members = members.split(/[\t ,]+/)
310         @groups[group] = { "members" => members }
311       end
312     end
313   end
314
315   def is_group(name)
316     return (name =~ /^@/)
317   end
318   def check_exists(x, whence, fatal=true)
319     ok=true
320     if is_group(x)
321       ok=false unless (@groups.has_key?(x))
322     else
323       ok=false unless @users.has_key?(x)
324     end
325     unless ok
326       STDERR.puts( (fatal ? "Error: " : "Warning: ") + "#{whence} contains unknown member #{x}")
327       exit(1) if fatal
328     end
329     return ok
330   end
331   def expand_groups
332     @groups.each_pair do |groupname, group|
333       group['members'].each do |member|
334         check_exists(member, "Group #{groupname}")
335       end
336       group['members_to_do'] = group['members'].clone
337     end
338
339     while true
340       had_progress = false
341       all_expanded = true
342       @groups.each_pair do |groupname, group|
343         group['keys'] = [] unless group['keys'] 
344
345         still_contains_groups = false
346         group['members_to_do'].clone.each do |member|
347           if is_group(member)
348             if @groups[member]['members_to_do'].size == 0
349               group['keys'].concat @groups[member]['keys']
350               group['members_to_do'].delete(member)
351               had_progress = true
352             else
353               still_contains_groups = true
354             end
355           else
356             group['keys'].push @users[member]
357             group['members_to_do'].delete(member)
358             had_progress = true
359           end
360         end
361         all_expanded = false if still_contains_groups
362       end
363       break if all_expanded
364       unless had_progress
365         cyclic_groups = @groups.keys.reject{|name| @groups[name]['members_to_do'].size == 0}.join(", ")
366         STDERR.puts "Cyclic group memberships in #{cyclic_groups}?"
367         exit(1)
368       end
369     end
370   end
371
372   def expand_targets(targets)
373     fprs = []
374     ok = true
375     targets.each do |t|
376       unless check_exists(t, "access line", false)
377         ok = false
378         next
379       end
380       if is_group(t)
381         fprs.concat @groups[t]['keys']
382       else
383         fprs.push @users[t]
384       end
385     end
386     return ok, fprs.uniq
387   end
388
389   def get_users()
390     return @users
391   end
392 end
393
394 class EncryptedFile
395   attr_reader :accessible, :encrypted, :readable, :readers
396
397   def EncryptedFile.determine_readable(readers)
398     GnuPG.get_my_keys.each do |keyid|
399       return true if readers.include?(keyid)
400     end
401     return false
402   end
403
404   def EncryptedFile.list_readers(statustxt)
405     readers = []
406     statustxt.split("\n").each do |line|
407       m = /^\[GNUPG:\] ENC_TO ([0-9A-F]+)/.match line
408       next unless m
409       readers.push m[1]
410     end
411     return readers
412   end
413
414   def EncryptedFile.targets(text)
415     text.split("\n").each do |line|
416       if /^#/.match line
417         next
418       end
419       m = /^access: "?((?:(?:#{GROUP_PATTERN}|#{USER_PATTERN}),?\s*)+)"?/.match line
420       return [] unless m
421       return m[1].strip.split(/[\t ,]+/)
422     end
423   end
424
425
426   def initialize(filename, new=false)
427     @groupconfig = GroupConfig.new
428     @new = new
429     if @new
430       @readers = []
431     end
432
433     @filename = filename
434     unless FileTest.readable?(filename)
435       @accessible = false
436       return
437     end
438     @accessible = true
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/)
442     if @encrypted
443       @readers = EncryptedFile.list_readers(statustxt)
444       @readable = EncryptedFile.determine_readable(@readers)
445     end
446   end
447
448   def decrypt
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
456     end
457
458     return outtxt
459   end
460
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")
465     args.push "--armor"
466     args.push "--encrypt"
467     (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, args)
468
469     invalid = []
470     statustxt.split("\n").each do |line|
471       m = /^\[GNUPG:\] INV_RECP \S+ ([0-9A-F]+)/.match line
472       next unless m
473       invalid.push m[1]
474     end
475     if invalid.size > 0
476       again = read_input("Warning: the following recipients are invalid: #{invalid.join(", ")}. Try again (or proceed)?")
477       return false if again
478     end
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
482       exit(0)
483     end
484     if exitstatus != 0
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
487     end
488
489     return true, outtxt
490   end
491
492
493   def determine_encryption_targets(content)
494     targets = EncryptedFile.targets(content)
495     if targets.size == 0
496       tryagain = read_input("Warning: Did not find targets to encrypt to in header.  Try again (or exit)?", true)
497       return false if tryagain
498       exit(0)
499     end
500
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
505       exit(0)
506     elsif (not ok)
507       tryagain = read_input("Warnings in access header.  Edit again (or continue)?", true)
508       return false if tryagain
509     end
510
511     to_me = false
512     GnuPG.get_my_fprs.each do |fpr|
513       if expanded.include?(fpr)
514         to_me = true
515         break
516       end
517     end
518     unless to_me
519       tryagain = read_input("File is not being encrypted to you.  Edit again (or continue)?", true)
520       return false if tryagain
521     end
522
523     return true, expanded
524   end
525
526   def write_back(content, targets)
527     ok, encrypted = encrypt(content, targets)
528     return false unless ok
529
530     File.open(@filename,"w").write(encrypted)
531     return true
532   end
533 end
534
535 class Ls
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."
542     exit(code)
543   end
544
545   def ls_dir(dirname)
546     begin
547       dir = Dir.open(dirname)
548     rescue Exception => e
549       STDERR.puts e
550       return
551     end
552     puts "#{dirname}:"
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?"
556         exit(1)
557       end
558       dir.sort.each do |filename|
559         next if (filename =~ /^\./) and not (@all >= 3)
560         stat = File::Stat.new(filename)
561         if stat.symlink?
562           puts "(sym)      #{filename}" if (@all >= 2)
563         elsif stat.directory?
564           puts "(dir)      #{filename}" if (@all >= 2)
565         elsif !stat.file?
566           puts "(other)    #{filename}" if (@all >= 2)
567         else
568           f = EncryptedFile.new(filename)
569           if !f.accessible
570             puts "(!perm)    #{filename}"
571           elsif !f.encrypted
572             puts "(file)     #{filename}" if (@all >= 2)
573           elsif f.readable
574             puts "(ok)       #{filename}"
575           else
576             puts "(locked)   #{filename}" if (@all >= 1)
577           end
578         end
579       end
580     end
581   end
582
583   def initialize()
584     @all = 0
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 }
588       opts.parse!
589     end
590
591     dirs = ARGV
592     dirs.push('.') unless dirs.size > 0
593     dirs.each { |dir| ls_dir(dir) }
594   end
595 end
596
597 class Ed
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"
602     exit(code)
603   end
604
605   def edit(filename)
606     encrypted_file = EncryptedFile.new(filename, @new)
607     if !@new and !encrypted_file.readable && !@force
608       STDERR.puts "#{filename} is probably not readable"
609       exit(1)
610     end
611
612     encrypted_to = GnuPG.get_fprs_from_keyids(encrypted_file.readers).sort
613
614     content = encrypted_file.decrypt
615     original_content = content
616     while true
617       oldsize = content.length
618       tempfile = Tempfile.open('pws')
619       tempfile.puts content
620       tempfile.flush
621       system($editor, tempfile.path)
622       status = $?
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
627       end
628
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.
631       begin
632         reopened = File.open(tempfile.path, "r+")
633       rescue Exception => e
634         STDERR.puts e
635         exit(1)
636       end
637       content = reopened.read
638
639       # zero the file, well, both of them.
640       newsize = content.length
641       clearsize = (newsize > oldsize) ? newsize : oldsize
642
643       [tempfile, reopened].each do |f|
644         f.seek(0, IO::SEEK_SET)
645         f.print "\0"*clearsize
646         f.fsync
647       end
648       reopened.close
649       tempfile.close(true)
650
651       if content.length == 0
652         proceed = read_input("Warning: Content is now empty.  Proceed?")
653         exit(0) unless proceed
654       end
655
656       ok, targets = encrypted_file.determine_encryption_targets(content)
657       next unless ok
658
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
663         else
664           STDERR.puts("Info: Content not changed but re-encrypting anyway because the list of keys changed")
665         end
666       end
667
668       success = encrypted_file.write_back(content, targets)
669       break if success
670     end
671   end
672
673   def initialize()
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 }
678       opts.parse!
679     end
680     help(ARGV.options, 1, STDERR) if ARGV.length != 1
681     filename = ARGV.shift
682
683     if @new
684       if FileTest.exists?(filename)
685         STDERR.puts "#{filename} does exist"
686         exit(1)
687       end
688     else
689       if !FileTest.exists?(filename)
690         STDERR.puts "#{filename} does not exist"
691         exit(1)
692       elsif !FileTest.file?(filename)
693         STDERR.puts "#{filename} is not a regular file"
694         exit(1)
695       elsif !FileTest.readable?(filename)
696         STDERR.puts "#{filename} is not accessible (unix perms)"
697         exit(1)
698       end
699     end
700
701     dirname = File.dirname(filename)
702     basename = File.basename(filename)
703     Dir.chdir(dirname) {
704       edit(basename)
705     }
706   end
707 end
708
709 class Get
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"
716     exit(code)
717   end
718
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"
723       exit(1)
724     end
725
726     begin
727       yaml = YAML::load(encrypted_file.decrypt)
728     rescue Psych::SyntaxError, ArgumentError => e
729       STDERR.puts "Could not parse YAML: #{e.message}"
730       exit(1)
731     end
732
733     require 'pp'
734
735     a = what.split("/")[1..-1]
736     hit = yaml
737     if a.nil?
738       # q = /, so print top level keys
739       puts "Keys:"
740       hit.keys.each do |k|
741         puts "- #{k}"
742       end
743       return
744     end
745     a.each do |k|
746       hit = hit[k]
747     end
748     if hit.nil?
749       STDERR.puts("No such key or invalid lookup expression")
750     elsif hit.respond_to?(:keys)
751       puts "Keys:"
752       hit.keys.each do |k|
753         puts "- #{k}"
754       end
755     else
756         puts hit
757     end
758   end
759
760   def initialize()
761     ARGV.options do |opts|
762       opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
763       opts.parse!
764     end
765     help(ARGV.options, 1, STDERR) if ARGV.length != 2
766     filename = ARGV.shift
767     what = ARGV.shift
768
769     if !FileTest.exists?(filename)
770       STDERR.puts "#{filename} does not exist"
771       exit(1)
772     elsif !FileTest.file?(filename)
773       STDERR.puts "#{filename} is not a regular file"
774       exit(1)
775     elsif !FileTest.readable?(filename)
776       STDERR.puts "#{filename} is not accessible (unix perms)"
777       exit(1)
778     end
779
780     dirname = File.dirname(filename)
781     basename = File.basename(filename)
782     Dir.chdir(dirname) {
783       get(basename, what)
784     }
785   end
786 end
787
788 class KeyringUpdater
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"
793     exit(code)
794   end
795
796   def initialize()
797     ARGV.options do |opts|
798       opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
799       opts.parse!
800     end
801     help(ARGV.options, 1, STDERR) if ARGV.length > 1
802     keyserver = ARGV.shift
803     keyserver = 'keys.gnupg.net' unless keyserver
804
805     groupconfig = GroupConfig.new
806     users = groupconfig.get_users()
807     args = %w{--with-colons --no-options --no-default-keyring --keyring=./.keyring}
808
809     system('touch', '.keyring')
810     users.each_pair() do |uid, keyid|
811       cmd = args.clone()
812       cmd << "--keyserver=#{keyserver}"
813       cmd << "--recv-keys"
814       cmd << keyid
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}"
824       end
825
826       cmd = args.clone()
827       cmd << '--batch' << '--edit' << keyid << 'minimize' << 'save'
828       (outtxt, stderrtxt, statustxt, ecode) = GnuPG.gpgcall('', cmd)
829     end
830
831
832   end
833 end
834
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"
841   exit(code)
842 end
843
844
845 def parse_command
846   case ARGV.shift
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
851     when 'help' then
852       case ARGV.length
853         when 0 then help
854         when 1 then
855           ARGV.push "--help"
856           parse_command
857         else help(1, STDERR)
858       end
859     else
860       help(1, STDERR)
861   end
862 end
863
864 parse_command
865
866 # vim:set shiftwidth=2:
867 # vim:set et:
868 # vim:set ts=2: