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