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